From cd6f05cdef907b4459a93de32b4679e5532c6ed1 Mon Sep 17 00:00:00 2001 From: Charlie Fish Date: Fri, 17 Apr 2026 19:03:30 -0600 Subject: [PATCH 1/3] Add dedicated Pixelfed integration Pixelfed implements the Mastodon-compatible REST API, so this reuses the masto client but adds a first-class `pixelfed` social network type with Pixelfed-specific behavior: - New SocialNetworkType.pixelfed enum value - New PixelfedSocialNetwork config interface (endpoint + bearer token) - Poster.post / Poster.reply use /api/v1/media + /api/v1/statuses - Image is required for posts and replies (Pixelfed is photo-first); throws a clear error otherwise - Polls /api/v1/media/:id while the server processes the upload (configurable via mediaProcessingPollAttempts / mediaProcessingPollIntervalMs) - Poster.repost reblogs an existing status by id - defaultIncludeHashtags returns true for pixelfed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 19 ------------ src/Pixelfed.test.ts | 25 +++++++++++++++ src/Poster.ts | 72 ++++++++++++++++++++++++++++++++++++++++++++ src/index.test.ts | 4 +++ src/types/Config.ts | 36 +++++++++++++++++++++- 5 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 src/Pixelfed.test.ts 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..6e88251 100644 --- a/src/Poster.ts +++ b/src/Poster.ts @@ -78,6 +78,31 @@ export default class Poster { return bluesky; } + async #waitForPixelfedMedia( + client: ReturnType, + uploaded: { id: string; url?: string | null }, + socialNetwork: SocialNetwork & {"type": SocialNetworkType.pixelfed} + ): Promise { + if (uploaded.url) { + return uploaded.id; + } + const maxAttempts = socialNetwork.settings?.mediaProcessingPollAttempts ?? 10; + const intervalMs = socialNetwork.settings?.mediaProcessingPollIntervalMs ?? 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 Pixelfed media ${uploaded.id} (${socialNetwork.uuid})`, e); + } + } + console.warn(`Pixelfed media ${uploaded.id} did not finish processing after ${maxAttempts} polls; attempting to publish anyway (${socialNetwork.uuid})`); + return uploaded.id; + } + async post(socialNetwork: SocialNetwork & {"type": SocialNetworkType.bluesky}, content: PostContent): Promise<{ "root": { uri: string; @@ -115,6 +140,25 @@ export default class Poster { ); return mastodonResult; } + case "pixelfed": { + if (!content.image) { + throw new Error(`Pixelfed posts require an image (${socialNetwork.uuid})`); + } + const pixelfed = Masto({ + "url": `${socialNetwork.credentials.endpoint}`, + "accessToken": socialNetwork.credentials.password + }); + const uploaded = await pixelfed.v1.media.create({ + "file": new Blob([new Uint8Array(content.image.content)], { "type": "image/png" }), + "description": content.image.alt ?? "" + }); + const mediaId = await this.#waitForPixelfedMedia(pixelfed, uploaded, socialNetwork); + const pixelfedResult = await pixelfed.v1.statuses.create({ + "status": content.message, + "mediaIds": [mediaId] + }); + return pixelfedResult; + } case "bluesky": { const bluesky = await this.#getBlueskyAgent(socialNetwork); @@ -342,6 +386,14 @@ export default class Poster { const mastodonResult = await masto.v1.statuses.$select(repost.id!).reblog(); return mastodonResult; } + case "pixelfed": { + const pixelfed = Masto({ + "url": `${socialNetwork.credentials.endpoint}`, + "accessToken": socialNetwork.credentials.password + }); + const pixelfedResult = await pixelfed.v1.statuses.$select(repost.id!).reblog(); + return pixelfedResult; + } case "bluesky": { const bluesky = await this.#getBlueskyAgent(socialNetwork); @@ -402,6 +454,26 @@ export default class Poster { "inReplyToId": replyTo.id }); return mastodonResult; + case SocialNetworkType.pixelfed: { + if (!content.image) { + throw new Error(`Pixelfed replies require an image (${socialNetwork.uuid})`); + } + const pixelfed = Masto({ + "url": `${socialNetwork.credentials.endpoint}`, + "accessToken": socialNetwork.credentials.password + }); + const uploaded = await pixelfed.v1.media.create({ + "file": new Blob([new Uint8Array(content.image.content)], { "type": "image/png" }), + "description": content.image.alt ?? "" + }); + const mediaId = await this.#waitForPixelfedMedia(pixelfed, uploaded, 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/types/Config.ts b/src/types/Config.ts index c61cb3a..6809cdb 100644 --- a/src/types/Config.ts +++ b/src/types/Config.ts @@ -31,6 +31,38 @@ interface MastodonSocialNetwork { }; 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; + /** + * The maximum number of times to poll Pixelfed for media processing + * completion before giving up and attempting to publish anyway. + * Defaults to 10. + */ + mediaProcessingPollAttempts?: number; + /** + * The number of milliseconds to wait between media processing polls. + * Defaults to 1000ms. + */ + mediaProcessingPollIntervalMs?: number; + }; + listen: boolean; +} interface BlueskySocialNetwork { uuid: `${string}-${string}-${string}-${string}-${string}` name: string; @@ -102,13 +134,14 @@ interface NostrSocialNetwork { eventHandler?: (event: { kind: number; content: string; tags: string[][] }) => Promise; 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 +152,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; From e46030d5458ca2715a3d7b077dda953e29c0fa21 Mon Sep 17 00:00:00 2001 From: Charlie Fish Date: Sat, 18 Apr 2026 13:41:44 -0600 Subject: [PATCH 2/3] Hoist media processing settings into a shared block + wire up Mastodon polling - New shared MediaProcessingSettings type (pollAttempts, pollIntervalMs) on src/types/Config.ts; both Mastodon and Pixelfed configs now expose it as settings.mediaProcessing. - Replace the Pixelfed-specific #waitForPixelfedMedia helper with a generic #waitForMastodonCompatibleMedia that works for any Mastodon-API-compatible network. - New #mastodonCompatibleClient helper centralizes masto client construction (used by post / repost / reply for both platforms). - New #uploadMastodonCompatibleImage helper combines media upload + polling so Mastodon and Pixelfed share the exact same flow. - Mastodon post / reply now poll uploaded media for processing completion (fixes a latent bug where large images or slow instances could be referenced before processing finished). Mastodon reply also now actually attaches images, which it previously dropped. - Export MediaProcessingSettings from the package entrypoint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Poster.ts | 93 +++++++++++++++++++++++---------------------- src/index.ts | 2 +- src/types/Config.ts | 33 ++++++++++------ 3 files changed, 70 insertions(+), 58 deletions(-) diff --git a/src/Poster.ts b/src/Poster.ts index 6e88251..0fee388 100644 --- a/src/Poster.ts +++ b/src/Poster.ts @@ -78,16 +78,17 @@ export default class Poster { return bluesky; } - async #waitForPixelfedMedia( + async #waitForMastodonCompatibleMedia( client: ReturnType, uploaded: { id: string; url?: string | null }, - socialNetwork: SocialNetwork & {"type": SocialNetworkType.pixelfed} + socialNetwork: SocialNetwork & {"type": SocialNetworkType.mastodon | SocialNetworkType.pixelfed} ): Promise { if (uploaded.url) { return uploaded.id; } - const maxAttempts = socialNetwork.settings?.mediaProcessingPollAttempts ?? 10; - const intervalMs = socialNetwork.settings?.mediaProcessingPollIntervalMs ?? 1000; + 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 { @@ -96,13 +97,32 @@ export default class Poster { return polled.id; } } catch (e) { - console.error(`Error polling Pixelfed media ${uploaded.id} (${socialNetwork.uuid})`, e); + console.error(`Error polling ${socialNetwork.type} media ${uploaded.id} (${socialNetwork.uuid})`, e); } } - console.warn(`Pixelfed media ${uploaded.id} did not finish processing after ${maxAttempts} polls; attempting to publish anyway (${socialNetwork.uuid})`); + 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; @@ -118,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); @@ -144,15 +158,8 @@ export default class Poster { if (!content.image) { throw new Error(`Pixelfed posts require an image (${socialNetwork.uuid})`); } - const pixelfed = Masto({ - "url": `${socialNetwork.credentials.endpoint}`, - "accessToken": socialNetwork.credentials.password - }); - const uploaded = await pixelfed.v1.media.create({ - "file": new Blob([new Uint8Array(content.image.content)], { "type": "image/png" }), - "description": content.image.alt ?? "" - }); - const mediaId = await this.#waitForPixelfedMedia(pixelfed, uploaded, socialNetwork); + 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] @@ -379,18 +386,12 @@ 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 = Masto({ - "url": `${socialNetwork.credentials.endpoint}`, - "accessToken": socialNetwork.credentials.password - }); + const pixelfed = this.#mastodonCompatibleClient(socialNetwork); const pixelfedResult = await pixelfed.v1.statuses.$select(repost.id!).reblog(); return pixelfedResult; } @@ -444,29 +445,29 @@ 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 = Masto({ - "url": `${socialNetwork.credentials.endpoint}`, - "accessToken": socialNetwork.credentials.password - }); - const uploaded = await pixelfed.v1.media.create({ - "file": new Blob([new Uint8Array(content.image.content)], { "type": "image/png" }), - "description": content.image.alt ?? "" - }); - const mediaId = await this.#waitForPixelfedMedia(pixelfed, uploaded, socialNetwork); + 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, 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 6809cdb..abc09c8 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,7 @@ interface MastodonSocialNetwork { }; settings?: { includeHashtags?: boolean; + mediaProcessing?: MediaProcessingSettings; }; listen: boolean; } @@ -49,17 +70,7 @@ interface PixelfedSocialNetwork { }; settings?: { includeHashtags?: boolean; - /** - * The maximum number of times to poll Pixelfed for media processing - * completion before giving up and attempting to publish anyway. - * Defaults to 10. - */ - mediaProcessingPollAttempts?: number; - /** - * The number of milliseconds to wait between media processing polls. - * Defaults to 1000ms. - */ - mediaProcessingPollIntervalMs?: number; + mediaProcessing?: MediaProcessingSettings; }; listen: boolean; } From c1df81c849cbb3c7bb403855b4191b668c8a9199 Mon Sep 17 00:00:00 2001 From: Charlie Fish Date: Sat, 18 Apr 2026 14:10:28 -0600 Subject: [PATCH 3/3] Fix lint errors blocking CI The repo's lint job was already failing on main with two pre-existing errors. Fixing them here so CI passes for this PR: - src/Poster.ts: nostrContent is never reassigned, change `let` to `const`. - src/types/Config.ts: NostrSocialNetwork.eventHandler return type was `Promise`. Replace with the explicit PostResult shape inlined (avoids a circular import with Poster.ts) so it satisfies @typescript-eslint/no-explicit-any without losing the structural contract that Poster.post relies on. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Poster.ts | 2 +- src/types/Config.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Poster.ts b/src/Poster.ts index 0fee388..0d1ab65 100644 --- a/src/Poster.ts +++ b/src/Poster.ts @@ -263,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; diff --git a/src/types/Config.ts b/src/types/Config.ts index abc09c8..11b15af 100644 --- a/src/types/Config.ts +++ b/src/types/Config.ts @@ -142,7 +142,14 @@ 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 | PixelfedSocialNetwork;