diff --git a/package-lock.json b/package-lock.json index e6fa245..cd395f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1003,7 +1003,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2144,7 +2143,6 @@ "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/core": "^0.22.12" } @@ -2181,7 +2179,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz", "integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -2194,7 +2191,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.22.12.tgz", "integrity": "sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -2219,7 +2215,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.22.12.tgz", "integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12", "tinycolor2": "^1.6.0" @@ -2263,7 +2258,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz", "integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -2387,7 +2381,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz", "integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -2400,7 +2393,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz", "integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -2416,7 +2408,6 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz", "integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -3635,7 +3626,6 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -4128,7 +4118,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4450,7 +4439,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4534,7 +4522,6 @@ "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -5040,7 +5027,6 @@ "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -6059,7 +6045,6 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -7396,7 +7381,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8447,7 +8431,6 @@ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8592,7 +8575,6 @@ "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -8851,7 +8833,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/src/Pixelfed.test.ts b/src/Pixelfed.test.ts new file mode 100644 index 0000000..b3d663f --- /dev/null +++ b/src/Pixelfed.test.ts @@ -0,0 +1,25 @@ +import Poster from "./Poster"; +import { SocialNetwork, SocialNetworkType } from "./types/Config"; + +describe("Pixelfed", () => { + const network: SocialNetwork & {"type": SocialNetworkType.pixelfed} = { + "uuid": "00000000-0000-0000-0000-000000000001", + "name": "test-pixelfed", + "type": SocialNetworkType.pixelfed, + "credentials": { + "endpoint": "https://pixelfed.example", + "password": "fake-token" + }, + "listen": false + }; + + test("post() rejects when no image is provided", async () => { + const poster = new Poster(); + await expect(poster.post(network, { "message": "hello" })).rejects.toThrow(/Pixelfed posts require an image/); + }); + + test("reply() rejects when no image is provided", async () => { + const poster = new Poster(); + await expect(poster.reply(network, { "id": "12345" }, { "message": "hi" })).rejects.toThrow(/Pixelfed replies require an image/); + }); +}); diff --git a/src/Poster.ts b/src/Poster.ts index fd9ebae..0d1ab65 100644 --- a/src/Poster.ts +++ b/src/Poster.ts @@ -78,6 +78,51 @@ export default class Poster { return bluesky; } + async #waitForMastodonCompatibleMedia( + client: ReturnType, + uploaded: { id: string; url?: string | null }, + socialNetwork: SocialNetwork & {"type": SocialNetworkType.mastodon | SocialNetworkType.pixelfed} + ): Promise { + if (uploaded.url) { + return uploaded.id; + } + const polling = socialNetwork.settings?.mediaProcessing; + const maxAttempts = polling?.pollAttempts ?? 10; + const intervalMs = polling?.pollIntervalMs ?? 1000; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + try { + const polled = await client.v1.media.$select(uploaded.id).fetch(); + if (polled.url) { + return polled.id; + } + } catch (e) { + console.error(`Error polling ${socialNetwork.type} media ${uploaded.id} (${socialNetwork.uuid})`, e); + } + } + console.warn(`${socialNetwork.type} media ${uploaded.id} did not finish processing after ${maxAttempts} polls; attempting to publish anyway (${socialNetwork.uuid})`); + return uploaded.id; + } + + #mastodonCompatibleClient(socialNetwork: SocialNetwork & {"type": SocialNetworkType.mastodon | SocialNetworkType.pixelfed}): ReturnType { + return Masto({ + "url": `${socialNetwork.credentials.endpoint}`, + "accessToken": socialNetwork.credentials.password + }); + } + + async #uploadMastodonCompatibleImage( + client: ReturnType, + image: NonNullable, + socialNetwork: SocialNetwork & {"type": SocialNetworkType.mastodon | SocialNetworkType.pixelfed} + ): Promise { + const uploaded = await client.v1.media.create({ + "file": new Blob([new Uint8Array(image.content)], { "type": "image/png" }), + "description": image.alt ?? "" + }); + return this.#waitForMastodonCompatibleMedia(client, uploaded, socialNetwork); + } + async post(socialNetwork: SocialNetwork & {"type": SocialNetworkType.bluesky}, content: PostContent): Promise<{ "root": { uri: string; @@ -93,17 +138,11 @@ export default class Poster { async post(socialNetwork: SocialNetwork, content: PostContent): Promise { switch (socialNetwork.type) { case "mastodon": { - const masto = Masto({ - "url": `${socialNetwork.credentials.endpoint}`, - "accessToken": socialNetwork.credentials.password - }); + const masto = this.#mastodonCompatibleClient(socialNetwork); let imageId: string | undefined; try { if (content.image) { - imageId = (await masto.v1.media.create({ - "file": new Blob([new Uint8Array(content.image.content)]), - "description": content.image.alt ?? "" - })).id; + imageId = await this.#uploadMastodonCompatibleImage(masto, content.image, socialNetwork); } } catch (e) { console.error("Error uploading Mastodon image", e); @@ -115,6 +154,18 @@ export default class Poster { ); return mastodonResult; } + case "pixelfed": { + if (!content.image) { + throw new Error(`Pixelfed posts require an image (${socialNetwork.uuid})`); + } + const pixelfed = this.#mastodonCompatibleClient(socialNetwork); + const mediaId = await this.#uploadMastodonCompatibleImage(pixelfed, content.image, socialNetwork); + const pixelfedResult = await pixelfed.v1.statuses.create({ + "status": content.message, + "mediaIds": [mediaId] + }); + return pixelfedResult; + } case "bluesky": { const bluesky = await this.#getBlueskyAgent(socialNetwork); @@ -212,7 +263,7 @@ export default class Poster { // Parse nostr mentions (NIP-27) — converts @npub/nostr:npub to canonical format and generates "p" tags const mentionResult = parseMentions(content.message); - let nostrContent = mentionResult.content; + const nostrContent = mentionResult.content; tags = [...tags, ...mentionResult.tags]; let imageURL: string | undefined; @@ -335,13 +386,15 @@ export default class Poster { async repost(socialNetwork: SocialNetwork, repost: PostResult): Promise { switch (socialNetwork.type) { case "mastodon": { - const masto = Masto({ - "url": `${socialNetwork.credentials.endpoint}`, - "accessToken": socialNetwork.credentials.password - }); + const masto = this.#mastodonCompatibleClient(socialNetwork); const mastodonResult = await masto.v1.statuses.$select(repost.id!).reblog(); return mastodonResult; } + case "pixelfed": { + const pixelfed = this.#mastodonCompatibleClient(socialNetwork); + const pixelfedResult = await pixelfed.v1.statuses.$select(repost.id!).reblog(); + return pixelfedResult; + } case "bluesky": { const bluesky = await this.#getBlueskyAgent(socialNetwork); @@ -392,16 +445,36 @@ export default class Poster { async reply(socialNetwork: SocialNetwork, replyTo: PostResult, content: PostContent): Promise; async reply(socialNetwork: SocialNetwork, replyTo: PostResult, content: PostContent): Promise { switch (socialNetwork.type) { - case SocialNetworkType.mastodon: - const masto = Masto({ - "url": `${socialNetwork.credentials.endpoint}`, - "accessToken": socialNetwork.credentials.password - }); + case SocialNetworkType.mastodon: { + const masto = this.#mastodonCompatibleClient(socialNetwork); + let imageId: string | undefined; + try { + if (content.image) { + imageId = await this.#uploadMastodonCompatibleImage(masto, content.image, socialNetwork); + } + } catch (e) { + console.error("Error uploading Mastodon image", e); + } const mastodonResult = await masto.v1.statuses.create({ "status": content.message, - "inReplyToId": replyTo.id + "inReplyToId": replyTo.id, + ...(imageId ? { "mediaIds": [imageId] } : {}) }); return mastodonResult; + } + case SocialNetworkType.pixelfed: { + if (!content.image) { + throw new Error(`Pixelfed replies require an image (${socialNetwork.uuid})`); + } + const pixelfed = this.#mastodonCompatibleClient(socialNetwork); + const mediaId = await this.#uploadMastodonCompatibleImage(pixelfed, content.image, socialNetwork); + const pixelfedResult = await pixelfed.v1.statuses.create({ + "status": content.message, + "inReplyToId": replyTo.id, + "mediaIds": [mediaId] + }); + return pixelfedResult; + } case SocialNetworkType.bluesky: const bluesky = await this.#getBlueskyAgent(socialNetwork); diff --git a/src/index.test.ts b/src/index.test.ts index 705949a..700e559 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -14,11 +14,15 @@ describe("Package exports", () => { expect(SocialNetworkType.mastodon).toBe("mastodon"); expect(SocialNetworkType.nostr).toBe("nostr"); expect(SocialNetworkType.s3).toBe("s3"); + expect(SocialNetworkType.pixelfed).toBe("pixelfed"); }); test("defaultIncludeHashtags should be exported", () => { expect(defaultIncludeHashtags(SocialNetworkType.mastodon)).toBe(true); expect(defaultIncludeHashtags(SocialNetworkType.bluesky)).toBe(true); + expect(defaultIncludeHashtags(SocialNetworkType.pixelfed)).toBe(true); + expect(defaultIncludeHashtags(SocialNetworkType.nostr)).toBe(true); + expect(defaultIncludeHashtags(SocialNetworkType.s3)).toBe(false); }); test("parseMentions should be exported", () => { diff --git a/src/index.ts b/src/index.ts index 82e028c..498f6c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export { default as Poster } from "./Poster"; export { handleExtraBlueskyFacets, PostContent, PostResult } from "./Poster"; -export { Config, SocialNetwork, SocialNetworkType, defaultIncludeHashtags } from "./types/Config"; +export { Config, SocialNetwork, SocialNetworkType, MediaProcessingSettings, defaultIncludeHashtags } from "./types/Config"; export { parseMentions, ParseMentionsResult } from "./utils/parseMentions"; diff --git a/src/types/Config.ts b/src/types/Config.ts index c61cb3a..11b15af 100644 --- a/src/types/Config.ts +++ b/src/types/Config.ts @@ -17,6 +17,26 @@ export interface Config { } } +/** + * Settings controlling how the client polls a server while it asynchronously + * processes an uploaded media attachment (e.g. transcoding, thumbnail + * generation). Applies to platforms whose media upload endpoint may return + * before the attachment is ready to be referenced from a post. + */ +export interface MediaProcessingSettings { + /** + * The maximum number of times to poll the server for media processing + * completion before giving up and attempting to publish anyway. + * Defaults to 10. + */ + pollAttempts?: number; + /** + * The number of milliseconds to wait between media processing polls. + * Defaults to 1000ms. + */ + pollIntervalMs?: number; +} + interface MastodonSocialNetwork { uuid: `${string}-${string}-${string}-${string}-${string}` name: string; @@ -28,6 +48,29 @@ interface MastodonSocialNetwork { }; settings?: { includeHashtags?: boolean; + mediaProcessing?: MediaProcessingSettings; + }; + listen: boolean; +} +interface PixelfedSocialNetwork { + uuid: `${string}-${string}-${string}-${string}-${string}` + name: string; + type: SocialNetworkType.pixelfed; + credentials: { + /** + * The base URL of the Pixelfed instance (e.g. `https://pixelfed.social`). + */ + endpoint: string; + /** + * OAuth bearer access token for the Pixelfed account. Pixelfed uses the + * Mastodon-compatible OAuth flow; the token must include at least the + * `write` scope to publish posts. + */ + password: string; + }; + settings?: { + includeHashtags?: boolean; + mediaProcessing?: MediaProcessingSettings; }; listen: boolean; } @@ -99,16 +142,24 @@ interface NostrSocialNetwork { * When using `eventHandler`, the `credentials.privateKey` and `credentials.relays` fields are * not used. */ - eventHandler?: (event: { kind: number; content: string; tags: string[][] }) => Promise; + eventHandler?: (event: { kind: number; content: string; tags: string[][] }) => Promise<{ + id?: string; + pubkey?: string; + parent?: { uri: string; cid: string }; + root?: { uri: string; cid: string }; + key?: string; + tags?: unknown; + } | undefined>; listen: boolean; } -export type SocialNetwork = MastodonSocialNetwork | S3SocialNetwork | BlueskySocialNetwork | NostrSocialNetwork; +export type SocialNetwork = MastodonSocialNetwork | S3SocialNetwork | BlueskySocialNetwork | NostrSocialNetwork | PixelfedSocialNetwork; export enum SocialNetworkType { mastodon = "mastodon", s3 = "s3", bluesky = "bluesky", nostr = "nostr", + pixelfed = "pixelfed", } /** @@ -119,6 +170,7 @@ export function defaultIncludeHashtags(type: SocialNetworkType): boolean { case SocialNetworkType.mastodon: case SocialNetworkType.nostr: case SocialNetworkType.bluesky: + case SocialNetworkType.pixelfed: return true; case SocialNetworkType.s3: return false;