Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 0 additions & 19 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions src/Pixelfed.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
111 changes: 92 additions & 19 deletions src/Poster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,51 @@ export default class Poster {
return bluesky;
}

async #waitForMastodonCompatibleMedia(
client: ReturnType<typeof Masto>,
uploaded: { id: string; url?: string | null },
socialNetwork: SocialNetwork & {"type": SocialNetworkType.mastodon | SocialNetworkType.pixelfed}
): Promise<string> {
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<typeof Masto> {
return Masto({
"url": `${socialNetwork.credentials.endpoint}`,
"accessToken": socialNetwork.credentials.password
});
}

async #uploadMastodonCompatibleImage(
client: ReturnType<typeof Masto>,
image: NonNullable<PostContent["image"]>,
socialNetwork: SocialNetwork & {"type": SocialNetworkType.mastodon | SocialNetworkType.pixelfed}
): Promise<string> {
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;
Expand All @@ -93,17 +138,11 @@ export default class Poster {
async post(socialNetwork: SocialNetwork, content: PostContent): Promise<PostResult | undefined> {
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);
Expand All @@ -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);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -335,13 +386,15 @@ export default class Poster {
async repost(socialNetwork: SocialNetwork, repost: PostResult): Promise<PostResult | undefined> {
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);

Expand Down Expand Up @@ -392,16 +445,36 @@ export default class Poster {
async reply(socialNetwork: SocialNetwork, replyTo: PostResult, content: PostContent): Promise<PostResult | undefined>;
async reply(socialNetwork: SocialNetwork, replyTo: PostResult, content: PostContent): Promise<PostResult | undefined> {
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);

Expand Down
4 changes: 4 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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";

Loading
Loading