Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
447
packages/connector-mastodon/src/contract.test.ts
Normal file
447
packages/connector-mastodon/src/contract.test.ts
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
|
||||
import { CapabilityNotSupportedError, InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing";
|
||||
import {
|
||||
AuthorizationRedirectRequired,
|
||||
createMastodonConnector,
|
||||
type MastodonAuthorizationRequest,
|
||||
} from "./index.js";
|
||||
|
||||
/**
|
||||
* Stubbed Mastodon instance: canned JSON per endpoint, real request/response
|
||||
* shapes. Every call is recorded so tests can assert on what was sent.
|
||||
*/
|
||||
interface StubCall {
|
||||
method: string;
|
||||
url: URL;
|
||||
init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
interface StubState {
|
||||
calls: StubCall[];
|
||||
nextStatusId: number;
|
||||
/** Idempotency-Key → status id, like the instance's one-hour dedupe cache. */
|
||||
idempotency?: Map<string, string>;
|
||||
/** Statuses actually created (side effects, independent of responses). */
|
||||
created?: string[];
|
||||
}
|
||||
|
||||
const INSTANCE = "https://mastodon.example";
|
||||
const HOST = "mastodon.example";
|
||||
const TOKEN = "stub-access-token";
|
||||
const REDIRECT = "https://app.example.com/oauth/mastodon/callback";
|
||||
|
||||
const ACCOUNT = {
|
||||
id: "109000000000000001",
|
||||
username: "anarchoposter",
|
||||
acct: "anarchoposter",
|
||||
display_name: "AnarchoPoster",
|
||||
avatar: `${INSTANCE}/avatars/original/missing.png`,
|
||||
url: `${INSTANCE}/@anarchoposter`,
|
||||
};
|
||||
|
||||
const OTHER = {
|
||||
id: "109000000000000002",
|
||||
username: "alice",
|
||||
acct: "alice@other.example",
|
||||
display_name: "Alice",
|
||||
avatar: "",
|
||||
url: "https://other.example/@alice",
|
||||
};
|
||||
|
||||
const connection: ConnectorConnection = {
|
||||
id: "conn-mastodon",
|
||||
workspaceId: "ws-1",
|
||||
provider: "mastodon",
|
||||
channelId: "conn-mastodon",
|
||||
credentialId: { kind: "credential-ref", id: "cred-1" },
|
||||
fields: { instanceUrl: HOST },
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
function status(
|
||||
id: string,
|
||||
overrides: Partial<Record<string, unknown>> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
created_at: "2026-10-06T12:00:00.000Z",
|
||||
url: `${INSTANCE}/@tactical-media-broadcast-hub/${id}`,
|
||||
uri: `${INSTANCE}/users/anarchoposter/statuses/${id}`,
|
||||
content: "<p>hello</p>",
|
||||
visibility: "public",
|
||||
account: ACCOUNT,
|
||||
media_attachments: [],
|
||||
mentions: [],
|
||||
reblogs_count: 0,
|
||||
replies_count: 0,
|
||||
favourites_count: 0,
|
||||
in_reply_to_id: null,
|
||||
reblog: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function json(body: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...init.headers },
|
||||
});
|
||||
}
|
||||
|
||||
function jsonBody(init: RequestInit | undefined): Record<string, unknown> {
|
||||
return JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function formBody(init: RequestInit | undefined): URLSearchParams {
|
||||
return new URLSearchParams(String(init?.body ?? ""));
|
||||
}
|
||||
|
||||
function headerOf(init: RequestInit | undefined, name: string): string | undefined {
|
||||
return new Headers(init?.headers).get(name) ?? undefined;
|
||||
}
|
||||
|
||||
function stubInstance(state: StubState): typeof fetch {
|
||||
return async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
||||
const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const url = new URL(raw);
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
state.calls.push({ method, url, init });
|
||||
const route = `${method} ${url.pathname}`;
|
||||
|
||||
if (route === "POST /api/v1/apps") {
|
||||
return json({
|
||||
id: "563419",
|
||||
name: formBody(init).get("client_name"),
|
||||
client_id: "stub-client-id",
|
||||
client_secret: "stub-client-secret",
|
||||
redirect_uri: formBody(init).get("redirect_uris"),
|
||||
});
|
||||
}
|
||||
if (route === "POST /oauth/token") {
|
||||
const form = formBody(init);
|
||||
if (form.get("code") !== "good-code") {
|
||||
return json({ error: "invalid_grant", error_description: "The provided authorization grant is invalid" }, { status: 400 });
|
||||
}
|
||||
return json({ access_token: TOKEN, token_type: "Bearer", scope: "read write:statuses write:media", created_at: 1_760_000_000 });
|
||||
}
|
||||
|
||||
// Everything below is an authenticated API call.
|
||||
if (headerOf(init, "authorization") !== `Bearer ${TOKEN}`) {
|
||||
return json({ error: "The access token is invalid" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (route === "GET /api/v1/accounts/verify_credentials") return json(ACCOUNT);
|
||||
|
||||
if (route === "POST /api/v1/statuses") {
|
||||
const body = jsonBody(init);
|
||||
const text = String(body["status"]);
|
||||
if (text === "rate-limit-me") return json({ error: "Too many requests" }, { status: 429, headers: { "retry-after": "30" } });
|
||||
if (text === "server-error-me") return json({ error: "Internal" }, { status: 500 });
|
||||
if (text === "invalid-me") return json({ error: "Validation failed: Text can't be blank" }, { status: 422 });
|
||||
const key = headerOf(init, "idempotency-key");
|
||||
const seen = key === undefined ? undefined : state.idempotency?.get(key);
|
||||
if (seen !== undefined) return json(status(seen, { content: `<p>${text}</p>` }));
|
||||
const id = String((state.nextStatusId += 1));
|
||||
if (key !== undefined) state.idempotency?.set(key, id);
|
||||
state.created?.push(id);
|
||||
if (typeof body["scheduled_at"] === "string") {
|
||||
return json({ id: `s${id}`, scheduled_at: body["scheduled_at"], params: { text: body["status"] }, media_attachments: [] });
|
||||
}
|
||||
return json(
|
||||
status(id, {
|
||||
content: `<p>${String(body["status"])}</p>`,
|
||||
visibility: body["visibility"] ?? "public",
|
||||
...(typeof body["quoted_status_id"] === "string"
|
||||
? { quote: { state: "accepted", quoted_status: status(body["quoted_status_id"], { account: OTHER }) } }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (route === "GET /api/v1/timelines/home") {
|
||||
return json(
|
||||
[
|
||||
status("300", {
|
||||
account: OTHER,
|
||||
content: "<p>first & foremost<br>line two</p>",
|
||||
media_attachments: [
|
||||
{ id: "m1", type: "image", url: "https://files.example/m1.png", preview_url: null },
|
||||
],
|
||||
reblogs_count: 2,
|
||||
replies_count: 1,
|
||||
favourites_count: 5,
|
||||
}),
|
||||
status("299", { reblog: status("250", { account: OTHER, content: "<p>boosted</p>" }) }),
|
||||
],
|
||||
{ headers: { link: `<${INSTANCE}/api/v1/timelines/home?max_id=299>; rel="next", <${INSTANCE}/api/v1/timelines/home?min_id=300>; rel="prev"` } },
|
||||
);
|
||||
}
|
||||
|
||||
const reblog = /^POST \/api\/v1\/statuses\/([^/]+)\/reblog$/.exec(route);
|
||||
if (reblog?.[1] !== undefined) {
|
||||
const original = status(decodeURIComponent(reblog[1]), { account: OTHER });
|
||||
return json(status(String((state.nextStatusId += 1)), { reblog: original, content: "" }));
|
||||
}
|
||||
|
||||
return json({ error: `stub: no route for ${route}` }, { status: 404 });
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
state: StubState;
|
||||
connector: ReturnType<typeof createMastodonConnector>;
|
||||
credentials: InMemoryCredentials;
|
||||
}
|
||||
|
||||
async function createHarness(seedToken = true): Promise<Harness> {
|
||||
const state: StubState = { calls: [], nextStatusId: 1000, idempotency: new Map(), created: [] };
|
||||
const credentials = new InMemoryCredentials();
|
||||
if (seedToken) await credentials.put(connection, "access_token", TOKEN);
|
||||
const connector = createMastodonConnector({
|
||||
fetchImpl: stubInstance(state),
|
||||
requestsPerSecond: 1000,
|
||||
sleep: async () => Promise.resolve(),
|
||||
credentials,
|
||||
});
|
||||
return { state, connector, credentials };
|
||||
}
|
||||
|
||||
const leg1: MastodonAuthorizationRequest = {
|
||||
redirectUri: REDIRECT,
|
||||
workspaceId: "ws-1",
|
||||
state: "csrf-1",
|
||||
fields: { instanceUrl: HOST },
|
||||
};
|
||||
|
||||
const post = (text: string) => ({
|
||||
text,
|
||||
mediaAssetIds: [],
|
||||
visibility: "public" as const,
|
||||
providerMetadata: {},
|
||||
});
|
||||
|
||||
connectorContractTests((await createHarness()).connector, { connection });
|
||||
|
||||
describe("mastodon connector: authorize + identity", () => {
|
||||
it("leg 1 registers an app on the instance and redirects to its consent page", async () => {
|
||||
const { state, connector } = await createHarness(false);
|
||||
const error = await connector
|
||||
.authorize(leg1)
|
||||
.then(
|
||||
() => undefined,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(error).toBeInstanceOf(AuthorizationRedirectRequired);
|
||||
const redirect = error as AuthorizationRedirectRequired;
|
||||
const consent = new URL(redirect.authorizationUrl);
|
||||
expect(consent.origin + consent.pathname).toBe(`${INSTANCE}/oauth/authorize`);
|
||||
expect(consent.searchParams.get("client_id")).toBe("stub-client-id");
|
||||
expect(consent.searchParams.get("redirect_uri")).toBe(REDIRECT);
|
||||
expect(consent.searchParams.get("state")).toBe("csrf-1");
|
||||
expect(redirect.connectionId).toBe(`mastodon:ws-1:${HOST}`);
|
||||
|
||||
const app = state.calls.find((c) => c.url.pathname === "/api/v1/apps");
|
||||
expect(app?.method).toBe("POST");
|
||||
expect(formBody(app?.init).get("scopes")).toBe("read write:statuses write:media");
|
||||
});
|
||||
|
||||
it("leg 2 exchanges the code and the identity roundtrips through getIdentity", async () => {
|
||||
const { state, connector } = await createHarness(false);
|
||||
await expect(connector.authorize(leg1)).rejects.toBeInstanceOf(AuthorizationRedirectRequired);
|
||||
|
||||
const leg2: MastodonAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } };
|
||||
const result = await connector.authorize(leg2);
|
||||
const token = state.calls.find((c) => c.url.pathname === "/oauth/token");
|
||||
const form = formBody(token?.init);
|
||||
expect(form.get("grant_type")).toBe("authorization_code");
|
||||
expect(form.get("client_id")).toBe("stub-client-id");
|
||||
expect(form.get("client_secret")).toBe("stub-client-secret");
|
||||
// Client registered once in leg 1, reused from the vault in leg 2.
|
||||
expect(state.calls.filter((c) => c.url.pathname === "/api/v1/apps")).toHaveLength(1);
|
||||
|
||||
expect(result.connection.fields.instanceUrl).toBe(INSTANCE);
|
||||
expect(result.identity).toEqual({
|
||||
provider: "mastodon",
|
||||
remoteId: ACCOUNT.id,
|
||||
handle: `@anarchoposter@${HOST}`,
|
||||
displayName: "AnarchoPoster",
|
||||
avatarUrl: ACCOUNT.avatar,
|
||||
});
|
||||
expect(await connector.getIdentity(result.connection)).toEqual(result.identity);
|
||||
});
|
||||
|
||||
it("maps a rejected code to a ConnectorError with the instance's description", async () => {
|
||||
const { connector } = await createHarness(false);
|
||||
const request: MastodonAuthorizationRequest = {
|
||||
...leg1,
|
||||
fields: { instanceUrl: HOST, clientId: "cid", clientSecret: "csecret" },
|
||||
providerMetadata: { code: "bad-code" },
|
||||
};
|
||||
await expect(connector.authorize(request)).rejects.toThrow(/authorization grant is invalid/);
|
||||
});
|
||||
|
||||
it("getIdentity sends the bearer token and caches verify_credentials", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const first = await connector.getIdentity(connection);
|
||||
const second = await connector.getIdentity(connection);
|
||||
expect(second).toEqual(first);
|
||||
const calls = state.calls.filter((c) => c.url.pathname === "/api/v1/accounts/verify_credentials");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(headerOf(calls[0]?.init, "authorization")).toBe(`Bearer ${TOKEN}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mastodon connector: publish", () => {
|
||||
it("returns a Publication carrying the status id and url", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const publication = await connector.publish?.(connection, post("hello fediverse"));
|
||||
const call = state.calls.find((c) => c.url.pathname === "/api/v1/statuses");
|
||||
expect(call?.method).toBe("POST");
|
||||
expect(jsonBody(call?.init)).toEqual({ status: "hello fediverse", visibility: "public" });
|
||||
expect(publication?.remoteId).toBe("1001");
|
||||
expect(publication?.remoteUrl).toBe(`${INSTANCE}/@tactical-media-broadcast-hub/1001`);
|
||||
expect(publication?.workspaceId).toBe("ws-1");
|
||||
expect(publication?.connectionId).toBe("conn-mastodon");
|
||||
expect(publication?.draftId).toBe("direct");
|
||||
expect(publication?.publishedAt).toEqual(new Date("2026-10-06T12:00:00.000Z"));
|
||||
expect(publication?.providerMetadata).toMatchObject({ visibility: "public" });
|
||||
});
|
||||
|
||||
it("surfaces a far-future schedule as provider_scheduled with a scheduled: remote id", async () => {
|
||||
const { connector } = await createHarness();
|
||||
const scheduledAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
const publication = await connector.publish?.(connection, {
|
||||
...post("later"),
|
||||
scheduledAt,
|
||||
providerMetadata: { draftId: "draft-from-user" },
|
||||
});
|
||||
expect(publication?.remoteId).toBe("scheduled:s1001");
|
||||
expect(publication?.publishedAt).toEqual(scheduledAt);
|
||||
expect(publication?.providerMetadata).toMatchObject({
|
||||
outcome: "provider_scheduled",
|
||||
scheduled: true,
|
||||
scheduledStatusId: "s1001",
|
||||
});
|
||||
// Caller metadata never becomes the publication's draft identity.
|
||||
expect(publication?.draftId).toBe("direct");
|
||||
});
|
||||
|
||||
it("sends the injected idempotency key and replays to the same status", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const key = "wp3:ws-1:draft-1:a1:publish";
|
||||
const withKey = { ...post("once"), providerMetadata: { idempotencyKey: key, draftId: "draft-1" } };
|
||||
const first = await connector.publish?.(connection, withKey);
|
||||
const replay = await connector.publish?.(connection, withKey);
|
||||
const posts = state.calls.filter((c) => c.method === "POST" && c.url.pathname === "/api/v1/statuses");
|
||||
expect(posts.map((c) => headerOf(c.init, "idempotency-key"))).toEqual([key, key]);
|
||||
expect(replay?.remoteId).toBe(first?.remoteId);
|
||||
expect(state.created).toEqual(["1001"]);
|
||||
expect(first?.providerMetadata).toMatchObject({ outcome: "published", idempotencyKey: key });
|
||||
expect(first?.draftId).toBe("direct");
|
||||
});
|
||||
|
||||
it("never resends a status POST on 5xx and reports unknown delivery", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
await expect(connector.publish?.(connection, post("server-error-me"))).rejects.toMatchObject({
|
||||
name: "ProviderServerError",
|
||||
status: 500,
|
||||
delivery: "unknown",
|
||||
});
|
||||
expect(state.calls.filter((c) => c.url.pathname === "/api/v1/statuses")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("surfaces a status POST 429 as a single-call RateLimitError with Retry-After", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
await expect(connector.publish?.(connection, post("rate-limit-me"))).rejects.toMatchObject({
|
||||
name: "RateLimitError",
|
||||
retryAfterMs: 30_000,
|
||||
delivery: "rejected",
|
||||
});
|
||||
expect(state.calls.filter((c) => c.url.pathname === "/api/v1/statuses")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("maps a validation 422 to ProviderRejectedError", async () => {
|
||||
const { connector } = await createHarness();
|
||||
await expect(connector.publish?.(connection, post("invalid-me"))).rejects.toMatchObject({
|
||||
name: "ProviderRejectedError",
|
||||
status: 422,
|
||||
delivery: "rejected",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a 401 to AuthError", async () => {
|
||||
const { connector, credentials } = await createHarness();
|
||||
await credentials.put(connection, "access_token", "revoked");
|
||||
await expect(connector.publish?.(connection, post("x"))).rejects.toMatchObject({ name: "AuthError" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mastodon connector: feed", () => {
|
||||
it("home returns a Page<FeedItem> with plain text, unwrapped reblogs and a Link cursor", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const page = await connector.getFeed?.(connection, { kind: "home", limit: 20, cursor: "400" });
|
||||
const call = state.calls.find((c) => c.url.pathname === "/api/v1/timelines/home");
|
||||
expect(call?.url.searchParams.get("limit")).toBe("20");
|
||||
expect(call?.url.searchParams.get("max_id")).toBe("400");
|
||||
|
||||
expect(page?.items).toHaveLength(2);
|
||||
expect(page?.nextCursor).toBe("299");
|
||||
const [first, boosted] = page?.items ?? [];
|
||||
expect(first).toEqual({
|
||||
id: "300",
|
||||
provider: "mastodon",
|
||||
remoteId: "300",
|
||||
authorHandle: "alice@other.example",
|
||||
text: "first & foremost\nline two",
|
||||
mediaUrls: ["https://files.example/m1.png"],
|
||||
createdAt: new Date("2026-10-06T12:00:00.000Z"),
|
||||
metrics: { reblogs: 2, replies: 1, favourites: 5 },
|
||||
});
|
||||
// The wrapper keeps its own id; content comes from the reblogged original.
|
||||
expect(boosted?.id).toBe("299");
|
||||
expect(boosted?.remoteId).toBe("250");
|
||||
expect(boosted?.text).toBe("boosted");
|
||||
});
|
||||
|
||||
it("refuses search queries that are not a single hashtag", async () => {
|
||||
const { connector } = await createHarness();
|
||||
await expect(
|
||||
connector.getFeed?.(connection, { kind: "search", query: "two words", limit: 10 }),
|
||||
).rejects.toThrow(/single hashtag/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mastodon connector: amplify", () => {
|
||||
it("repost reblogs the target and returns a Publication", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const publication = await connector.amplify?.(connection, { kind: "repost", targetRemoteId: "250" });
|
||||
const call = state.calls.find((c) => c.url.pathname === "/api/v1/statuses/250/reblog");
|
||||
expect(call?.method).toBe("POST");
|
||||
expect(publication?.remoteId).toBe("1001");
|
||||
expect(publication?.draftId).toBe("amplify:repost");
|
||||
expect(publication?.remoteUrl).toBe(`${INSTANCE}/@tactical-media-broadcast-hub/1001`);
|
||||
expect(publication?.providerMetadata).toMatchObject({ amplifies: "250", kind: "repost" });
|
||||
});
|
||||
|
||||
it("quote posts with quoted_status_id on a 4.5+ instance", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const publication = await connector.amplify?.(connection, {
|
||||
kind: "quote",
|
||||
targetRemoteId: "250",
|
||||
commentary: "worth a read",
|
||||
});
|
||||
const call = state.calls.find((c) => c.url.pathname === "/api/v1/statuses");
|
||||
expect(jsonBody(call?.init)).toEqual({ status: "worth a read", quoted_status_id: "250" });
|
||||
expect(publication?.providerMetadata).toMatchObject({ kind: "quote", quoteState: "accepted" });
|
||||
});
|
||||
|
||||
it("share is not a Mastodon amplification kind", async () => {
|
||||
const { connector } = await createHarness();
|
||||
await expect(
|
||||
connector.amplify?.(connection, { kind: "share", targetRemoteId: "250" }),
|
||||
).rejects.toBeInstanceOf(CapabilityNotSupportedError);
|
||||
});
|
||||
});
|
||||
985
packages/connector-mastodon/src/index.ts
Normal file
985
packages/connector-mastodon/src/index.ts
Normal file
|
|
@ -0,0 +1,985 @@
|
|||
/**
|
||||
* Mastodon connector — Mastodon REST API, one base URL per connection.
|
||||
*
|
||||
* Auth: per-instance OAuth2. Every instance is its own authorization server,
|
||||
* so `authorize` registers an app on the instance (POST /api/v1/apps) unless
|
||||
* client credentials are supplied, then runs a standard authorization-code
|
||||
* exchange (POST /oauth/token). The instance comes from the connection field
|
||||
* `instanceUrl`; a bare host gets an https:// prefix, plain http is refused
|
||||
* outside localhost.
|
||||
*
|
||||
* Honest capability notes (verified against the Mastodon API):
|
||||
* - Native scheduling: POST /api/v1/statuses with `scheduled_at` returns a
|
||||
* ScheduledStatus, not a Status. We surface it as remoteId
|
||||
* `scheduled:<id>` so deletePublication can cancel it, flagged with
|
||||
* providerMetadata.outcome = "provider_scheduled": it is NOT a published
|
||||
* status. Mastodon rejects `scheduled_at` under 5 minutes out, so nearer
|
||||
* times publish immediately. The publishing pipeline strips scheduledAt and
|
||||
* owns timing itself, so it always receives a real status.
|
||||
* - Idempotency: providerMetadata.idempotencyKey (injected by the publishing
|
||||
* runtime) is sent as the Idempotency-Key header; Mastodon dedupes repeated
|
||||
* POSTs with the same key for up to one hour. Status POSTs are never
|
||||
* auto-retried by the HTTP client — retry decisions belong to the runtime.
|
||||
* - Quote posts need Mastodon ≥ 4.5. Older servers silently drop
|
||||
* `quoted_status_id` and publish a plain post; we detect the missing
|
||||
* `quote` field, delete that post and throw CapabilityNotSupportedError.
|
||||
* - Mentions come from GET /api/v1/notifications?types[]=mention — Mastodon
|
||||
* has no separate "reply" notification type; replies are mentions.
|
||||
* - Search maps to hashtag timelines only (GET /api/v1/timelines/tag/:tag).
|
||||
* - No webhooks for user accounts (only admin webhooks), no reactions
|
||||
* surface, no analytics API.
|
||||
*
|
||||
* Rate limits: 300 requests / 5 min per account by default. The SDK client
|
||||
* runs at 0.5 requests/s per host — half the account budget, shared across
|
||||
* every connection on the same instance.
|
||||
*/
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type {
|
||||
Comment,
|
||||
ConnectorCapabilities,
|
||||
ConnectorConnection,
|
||||
CredentialRef,
|
||||
DirectMessage,
|
||||
FeedItem,
|
||||
MediaAsset,
|
||||
NormalizedIdentity,
|
||||
NormalizedPost,
|
||||
Page,
|
||||
Publication,
|
||||
RemoteMedia,
|
||||
Visibility,
|
||||
} from "@tactical-media-broadcast-hub/core";
|
||||
import {
|
||||
assertValidManifest,
|
||||
AuthError,
|
||||
CapabilityNotSupportedError,
|
||||
ConnectorError,
|
||||
InMemoryCredentials,
|
||||
ProviderRejectedError,
|
||||
pageOf,
|
||||
RateLimitedHttpClient,
|
||||
type AuthorizationRequest,
|
||||
type AuthorizationResult,
|
||||
type ConnectorManifest,
|
||||
type CredentialAccessor,
|
||||
type PlatformConnector,
|
||||
} from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
|
||||
const PROVIDER = "mastodon";
|
||||
const SCOPES = ["read", "write:statuses", "write:media"];
|
||||
const MAX_MEDIA = 4;
|
||||
const MIN_SCHEDULE_LEAD_MS = 5 * 60 * 1000;
|
||||
/** Conversations pages scanned when resolving a conversation id to its thread. */
|
||||
const CONVERSATION_SCAN_PAGES = 3;
|
||||
|
||||
const CAPABILITIES: ConnectorCapabilities = {
|
||||
publishText: true,
|
||||
publishImages: true,
|
||||
publishCarousel: true,
|
||||
feed: true,
|
||||
mentions: true,
|
||||
comments: true,
|
||||
replies: true,
|
||||
amplifyRepost: true,
|
||||
amplifyQuote: true,
|
||||
directMessages: true,
|
||||
nativeScheduling: true,
|
||||
deletePublication: true,
|
||||
webhooks: false,
|
||||
reactions: false,
|
||||
analytics: false,
|
||||
publishVideo: false,
|
||||
};
|
||||
|
||||
/** Credential keys stored per connection through the CredentialAccessor. */
|
||||
const SECRET = {
|
||||
accessToken: "access_token",
|
||||
refreshToken: "refresh_token",
|
||||
clientId: "client_id",
|
||||
clientSecret: "client_secret",
|
||||
} as const;
|
||||
|
||||
export interface MastodonConnectorOptions {
|
||||
env?: Record<string, string | undefined>;
|
||||
/** fetch override for tests (same pattern as the other connectors). */
|
||||
fetchImpl?: typeof fetch;
|
||||
/** Requests per second per instance host. Default 0.5; tests raise it. */
|
||||
requestsPerSecond?: number;
|
||||
/** Sleep override for the rate limiter (tests). */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
/** Secret store. Default: in-memory (dev + contract tests). */
|
||||
credentials?: CredentialAccessor;
|
||||
/** Resolve a media asset id to its metadata (needed by publish with media). */
|
||||
loadAsset?: (assetId: string) => Promise<MediaAsset>;
|
||||
/** Read asset bytes for multipart upload (needed by publish/uploadMedia). */
|
||||
openAsset?: (media: MediaAsset) => Promise<{ bytes: Uint8Array; filename: string }>;
|
||||
/** client_name shown on the instance's authorized-apps page. */
|
||||
clientName?: string;
|
||||
/** Public website advertised with the registered app. */
|
||||
website?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK's AuthorizationRequest carries no provider input, so the second
|
||||
* leg (code exchange) and the instance URL travel in these optional fields.
|
||||
*/
|
||||
export interface MastodonAuthorizationRequest extends AuthorizationRequest {
|
||||
fields?: Record<string, string>;
|
||||
providerMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by the first authorize leg. AuthorizationResult has no slot for a
|
||||
* redirect, so the authorization URL rides on this error; callers send the
|
||||
* user there and call authorize again with providerMetadata.code.
|
||||
*/
|
||||
export class AuthorizationRedirectRequired extends ConnectorError {
|
||||
constructor(
|
||||
readonly authorizationUrl: string,
|
||||
readonly connectionId: string,
|
||||
) {
|
||||
super("redirect the user to the instance to authorize", PROVIDER);
|
||||
this.name = "AuthorizationRedirectRequired";
|
||||
}
|
||||
}
|
||||
|
||||
interface MastodonAccount {
|
||||
id: string;
|
||||
username: string;
|
||||
acct: string;
|
||||
display_name: string;
|
||||
avatar: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface MastodonMediaAttachment {
|
||||
id: string;
|
||||
type: string;
|
||||
url: string | null;
|
||||
preview_url: string | null;
|
||||
meta?: { original?: { width?: number; height?: number } } | null;
|
||||
}
|
||||
|
||||
interface MastodonStatus {
|
||||
id: string;
|
||||
created_at: string;
|
||||
url: string | null;
|
||||
uri: string;
|
||||
content: string;
|
||||
visibility: "public" | "unlisted" | "private" | "direct";
|
||||
account: MastodonAccount;
|
||||
media_attachments: MastodonMediaAttachment[];
|
||||
mentions: { id: string; acct: string; username: string }[];
|
||||
reblogs_count: number;
|
||||
replies_count: number;
|
||||
favourites_count: number;
|
||||
in_reply_to_id: string | null;
|
||||
reblog: MastodonStatus | null;
|
||||
/** Mastodon ≥ 4.5 only. */
|
||||
quote?: { state: string; quoted_status?: MastodonStatus | null } | null;
|
||||
}
|
||||
|
||||
interface MastodonScheduledStatus {
|
||||
id: string;
|
||||
scheduled_at: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface MastodonNotification {
|
||||
id: string;
|
||||
type: string;
|
||||
created_at: string;
|
||||
account: MastodonAccount;
|
||||
status?: MastodonStatus | null;
|
||||
}
|
||||
|
||||
interface MastodonConversation {
|
||||
id: string;
|
||||
unread: boolean;
|
||||
accounts: MastodonAccount[];
|
||||
last_status: MastodonStatus | null;
|
||||
}
|
||||
|
||||
interface MastodonContext {
|
||||
ancestors: MastodonStatus[];
|
||||
descendants: MastodonStatus[];
|
||||
}
|
||||
|
||||
interface MastodonApp {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}
|
||||
|
||||
interface MastodonToken {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
scope: string;
|
||||
created_at: number;
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
function str(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** Accepts "mastodon.social", "https://mastodon.social/", etc. → origin. */
|
||||
export function normalizeInstanceUrl(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) throw new ConnectorError("instanceUrl is required", PROVIDER);
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(withScheme);
|
||||
} catch (cause) {
|
||||
throw new ConnectorError(`instanceUrl "${raw}" is not a valid URL`, PROVIDER, cause);
|
||||
}
|
||||
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
||||
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) {
|
||||
throw new ConnectorError(`instanceUrl must use https (got ${url.protocol})`, PROVIDER);
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
const VISIBILITY: Record<Visibility, MastodonStatus["visibility"] | undefined> = {
|
||||
public: "public",
|
||||
unlisted: "unlisted",
|
||||
followers: "private",
|
||||
private: "direct",
|
||||
provider_default: undefined,
|
||||
};
|
||||
|
||||
const ENTITIES: Record<string, string> = {
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
nbsp: " ",
|
||||
};
|
||||
|
||||
/** Status content is sanitized HTML; FeedItem.text is plain text. */
|
||||
export function htmlToText(html: string): string {
|
||||
return html
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>\s*<p[^>]*>/gi, "\n\n")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {
|
||||
if (entity.startsWith("#x") || entity.startsWith("#X")) {
|
||||
return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
|
||||
}
|
||||
if (entity.startsWith("#")) return String.fromCodePoint(Number(entity.slice(1)));
|
||||
return ENTITIES[entity.toLowerCase()] ?? match;
|
||||
})
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** `Link: <…?max_id=123>; rel="next"` → "123". */
|
||||
function nextMaxId(response: Response): string | undefined {
|
||||
const link = response.headers.get("link");
|
||||
if (link === null) return undefined;
|
||||
for (const part of link.split(",")) {
|
||||
const match = /<([^>]+)>\s*;\s*rel="?next"?/.exec(part);
|
||||
if (match?.[1] !== undefined) {
|
||||
return new URL(match[1]).searchParams.get("max_id") ?? undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isScheduled(value: MastodonStatus | MastodonScheduledStatus): value is MastodonScheduledStatus {
|
||||
return "scheduled_at" in value && "params" in value && !("content" in value);
|
||||
}
|
||||
|
||||
export function createMastodonConnector(options: MastodonConnectorOptions = {}): PlatformConnector {
|
||||
const client = new RateLimitedHttpClient({
|
||||
requestsPerSecond: options.requestsPerSecond ?? 0.5,
|
||||
fetchImpl: options.fetchImpl,
|
||||
sleep: options.sleep,
|
||||
});
|
||||
const credentials = options.credentials ?? new InMemoryCredentials();
|
||||
/** verify_credentials results per connection id — reply/DM mapping need "self". */
|
||||
const accounts = new Map<string, MastodonAccount>();
|
||||
|
||||
const manifest: ConnectorManifest = {
|
||||
id: PROVIDER,
|
||||
displayName: "Mastodon",
|
||||
capabilities: CAPABILITIES,
|
||||
connectionFields: [
|
||||
{
|
||||
name: "instanceUrl",
|
||||
label: "Instance URL",
|
||||
required: true,
|
||||
secret: false,
|
||||
placeholder: "mastodon.social",
|
||||
},
|
||||
{
|
||||
name: "clientId",
|
||||
label: "Client key (optional — registered automatically)",
|
||||
required: false,
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
name: "clientSecret",
|
||||
label: "Client secret (optional — registered automatically)",
|
||||
required: false,
|
||||
secret: true,
|
||||
},
|
||||
],
|
||||
oauth: {
|
||||
kind: "per-instance-oauth2",
|
||||
authorizeUrlTemplate: "{instanceUrl}/oauth/authorize",
|
||||
scopes: SCOPES,
|
||||
pkce: true,
|
||||
},
|
||||
docsUrl: "https://docs.joinmastodon.org/client/intro/",
|
||||
};
|
||||
|
||||
function instanceOf(connection: ConnectorConnection): string {
|
||||
const raw = connection.fields.instanceUrl;
|
||||
if (raw === undefined) {
|
||||
throw new ConnectorError(`connection ${connection.id} has no instanceUrl`, PROVIDER);
|
||||
}
|
||||
return normalizeInstanceUrl(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws the SDK error taxonomy for non-2xx: AuthError for 401/403,
|
||||
* ProviderRejectedError for other 4xx; 429/5xx/transport failures arrive as
|
||||
* typed errors from the client (never auto-retried for POST).
|
||||
*/
|
||||
async function send(url: string, init: RequestInit): Promise<Response> {
|
||||
const response = await client.request(PROVIDER, url, {
|
||||
...init,
|
||||
headers: { accept: "application/json", ...init.headers },
|
||||
});
|
||||
if (response.ok) return response;
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string; error_description?: string };
|
||||
detail = body.error_description ?? body.error ?? "";
|
||||
} catch {
|
||||
detail = "";
|
||||
}
|
||||
const message = `HTTP ${response.status} ${init.method ?? "GET"} ${new URL(url).pathname}${detail ? `: ${detail}` : ""}`;
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new AuthError(message, PROVIDER, false);
|
||||
}
|
||||
throw new ProviderRejectedError(message, PROVIDER, response.status);
|
||||
}
|
||||
|
||||
async function api<T>(
|
||||
connection: ConnectorConnection,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: Record<string, unknown> | FormData,
|
||||
extraHeaders: Record<string, string> = {},
|
||||
): Promise<{ data: T; response: Response }> {
|
||||
const token = await credentials.getAccessToken(connection);
|
||||
const headers: Record<string, string> = { authorization: `Bearer ${token}`, ...extraHeaders };
|
||||
const init: RequestInit = { method, headers };
|
||||
if (body instanceof FormData) {
|
||||
init.body = body;
|
||||
} else if (body !== undefined) {
|
||||
headers["content-type"] = "application/json";
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const response = await send(`${instanceOf(connection)}${path}`, init);
|
||||
const text = await response.text();
|
||||
const data = (text.length > 0 ? JSON.parse(text) : {}) as T;
|
||||
return { data, response };
|
||||
}
|
||||
|
||||
async function form<T>(url: string, params: Record<string, string>): Promise<T> {
|
||||
const response = await send(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(params).toString(),
|
||||
});
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function selfAccount(connection: ConnectorConnection): Promise<MastodonAccount> {
|
||||
const cached = accounts.get(connection.id);
|
||||
if (cached !== undefined) return cached;
|
||||
const { data } = await api<MastodonAccount>(connection, "GET", "/api/v1/accounts/verify_credentials");
|
||||
accounts.set(connection.id, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
function identityOf(connection: ConnectorConnection, account: MastodonAccount): NormalizedIdentity {
|
||||
const host = new URL(instanceOf(connection)).host;
|
||||
const identity: NormalizedIdentity = {
|
||||
provider: PROVIDER,
|
||||
remoteId: account.id,
|
||||
handle: `@${account.username}@${host}`,
|
||||
};
|
||||
if (account.display_name) identity.displayName = account.display_name;
|
||||
if (account.avatar) identity.avatarUrl = account.avatar;
|
||||
return identity;
|
||||
}
|
||||
|
||||
function toPublication(
|
||||
connection: ConnectorConnection,
|
||||
status: MastodonStatus,
|
||||
draftId: string,
|
||||
providerMetadata: Record<string, unknown> = {},
|
||||
): Publication {
|
||||
const publication: Publication = {
|
||||
id: randomUUID(),
|
||||
workspaceId: connection.workspaceId,
|
||||
draftId,
|
||||
connectionId: connection.id,
|
||||
remoteId: status.id,
|
||||
publishedAt: new Date(status.created_at),
|
||||
providerMetadata: { visibility: status.visibility, ...providerMetadata },
|
||||
};
|
||||
const url = status.url ?? status.uri;
|
||||
if (url) publication.remoteUrl = url;
|
||||
return publication;
|
||||
}
|
||||
|
||||
function toFeedItem(status: MastodonStatus): FeedItem {
|
||||
const content = status.reblog ?? status;
|
||||
return {
|
||||
id: status.id,
|
||||
provider: PROVIDER,
|
||||
remoteId: content.id,
|
||||
authorHandle: content.account.acct,
|
||||
text: htmlToText(content.content),
|
||||
mediaUrls: content.media_attachments
|
||||
.map((m) => m.url ?? m.preview_url)
|
||||
.filter((u): u is string => u !== null),
|
||||
createdAt: new Date(content.created_at),
|
||||
metrics: {
|
||||
reblogs: content.reblogs_count,
|
||||
replies: content.replies_count,
|
||||
favourites: content.favourites_count,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadAsset(
|
||||
connection: ConnectorConnection,
|
||||
media: MediaAsset,
|
||||
description?: string,
|
||||
): Promise<RemoteMedia> {
|
||||
if (options.openAsset === undefined) {
|
||||
throw new ConnectorError("media upload needs an openAsset option", PROVIDER);
|
||||
}
|
||||
const { bytes, filename } = await options.openAsset(media);
|
||||
const body = new FormData();
|
||||
body.append("file", new Blob([new Uint8Array(bytes)], { type: media.mimeType }), filename);
|
||||
if (description !== undefined) body.append("description", description);
|
||||
// 202 = still processing; /api/v1/statuses accepts the id immediately.
|
||||
const { data } = await api<MastodonMediaAttachment>(connection, "POST", "/api/v2/media", body);
|
||||
const remote: RemoteMedia = { remoteId: data.id, mimeType: media.mimeType };
|
||||
if (data.url !== null) remote.url = data.url;
|
||||
const width = data.meta?.original?.width ?? media.width;
|
||||
const height = data.meta?.original?.height ?? media.height;
|
||||
if (width !== undefined) remote.width = width;
|
||||
if (height !== undefined) remote.height = height;
|
||||
return remote;
|
||||
}
|
||||
|
||||
/** Asset ids → instance media ids; providerMetadata.altText maps asset id → description. */
|
||||
async function uploadAll(
|
||||
connection: ConnectorConnection,
|
||||
assetIds: string[],
|
||||
altText: Record<string, unknown> = {},
|
||||
): Promise<string[]> {
|
||||
if (assetIds.length === 0) return [];
|
||||
if (assetIds.length > MAX_MEDIA) {
|
||||
throw new ConnectorError(`at most ${MAX_MEDIA} media attachments per status`, PROVIDER);
|
||||
}
|
||||
if (options.loadAsset === undefined) {
|
||||
throw new ConnectorError("publishing media needs a loadAsset option", PROVIDER);
|
||||
}
|
||||
const ids: string[] = [];
|
||||
for (const assetId of assetIds) {
|
||||
const asset = await options.loadAsset(assetId);
|
||||
ids.push((await uploadAsset(connection, asset, str(altText[assetId]))).remoteId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function getStatus(connection: ConnectorConnection, id: string): Promise<MastodonStatus> {
|
||||
const { data } = await api<MastodonStatus>(connection, "GET", `/api/v1/statuses/${encodeURIComponent(id)}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function deleteStatus(connection: ConnectorConnection, id: string): Promise<void> {
|
||||
await api(connection, "DELETE", `/api/v1/statuses/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async function statusPage(
|
||||
connection: ConnectorConnection,
|
||||
path: string,
|
||||
params: URLSearchParams,
|
||||
limit: number,
|
||||
): Promise<Page<FeedItem>> {
|
||||
const { data, response } = await api<MastodonStatus[]>(connection, "GET", `${path}?${params}`);
|
||||
const items = data.map(toFeedItem);
|
||||
const fallback = data.length >= limit ? data[data.length - 1]?.id : undefined;
|
||||
return pageOf(items, nextMaxId(response) ?? fallback);
|
||||
}
|
||||
|
||||
async function storeTokens(connection: ConnectorConnection, token: MastodonToken): Promise<void> {
|
||||
await credentials.put(connection, SECRET.accessToken, token.access_token);
|
||||
if (token.refresh_token !== undefined) {
|
||||
await credentials.put(connection, SECRET.refreshToken, token.refresh_token);
|
||||
}
|
||||
accounts.delete(connection.id);
|
||||
}
|
||||
|
||||
async function secretOrUndefined(connection: ConnectorConnection, key: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await credentials.getSecret(connection, key);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Client credentials: vault first, then connection fields. */
|
||||
async function clientCredentials(
|
||||
connection: ConnectorConnection,
|
||||
): Promise<{ clientId: string; clientSecret: string } | undefined> {
|
||||
const clientId = (await secretOrUndefined(connection, SECRET.clientId)) ?? str(connection.fields.clientId);
|
||||
const clientSecret =
|
||||
(await secretOrUndefined(connection, SECRET.clientSecret)) ?? str(connection.fields.clientSecret);
|
||||
return clientId !== undefined && clientSecret !== undefined ? { clientId, clientSecret } : undefined;
|
||||
}
|
||||
|
||||
function messageOf(
|
||||
conversationRemoteId: string,
|
||||
status: MastodonStatus,
|
||||
self: MastodonAccount,
|
||||
participants: MastodonAccount[],
|
||||
): DirectMessage {
|
||||
const recipients = status.mentions
|
||||
.filter((m) => m.id !== status.account.id)
|
||||
.map((m) => m.acct);
|
||||
const fallback = participants.filter((a) => a.id !== status.account.id).map((a) => a.acct);
|
||||
const to = recipients.length > 0 ? recipients : fallback.length > 0 ? fallback : [self.acct];
|
||||
return {
|
||||
id: status.id,
|
||||
provider: PROVIDER,
|
||||
conversationRemoteId,
|
||||
fromHandle: status.account.acct,
|
||||
toHandle: to.join(","),
|
||||
text: htmlToText(status.content),
|
||||
sentAt: new Date(status.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
const connector: PlatformConnector = {
|
||||
manifest: () => {
|
||||
assertValidManifest(manifest);
|
||||
return manifest;
|
||||
},
|
||||
|
||||
async buildAuthorizationUrl(input) {
|
||||
try {
|
||||
await connector.authorize(input);
|
||||
throw new ConnectorError("authorization redirect missing", PROVIDER);
|
||||
} catch (error) {
|
||||
if (!(error instanceof AuthorizationRedirectRequired)) throw error;
|
||||
const connection: ConnectorConnection = {
|
||||
id: error.connectionId, workspaceId: input.workspaceId, provider: PROVIDER,
|
||||
channelId: error.connectionId, credentialId: { kind: "credential-ref", id: error.connectionId },
|
||||
fields: { instanceUrl: normalizeInstanceUrl(input.fields?.instanceUrl ?? "") }, createdAt: new Date(),
|
||||
};
|
||||
return { authorizationUrl: error.authorizationUrl, connection };
|
||||
}
|
||||
},
|
||||
|
||||
async exchangeCode(input) {
|
||||
return connector.authorize({ ...input, providerMetadata: { code: input.code } } as MastodonAuthorizationRequest);
|
||||
},
|
||||
|
||||
/**
|
||||
* Leg 1 (no code): register an app on the instance when no client
|
||||
* credentials exist, store them, throw AuthorizationRedirectRequired with
|
||||
* the consent URL. Leg 2 (providerMetadata.code): exchange the code,
|
||||
* store tokens, return the connection and identity.
|
||||
*/
|
||||
async authorize(input: MastodonAuthorizationRequest): Promise<AuthorizationResult> {
|
||||
const meta = input.providerMetadata ?? {};
|
||||
const rawInstance = str(input.fields?.instanceUrl) ?? str(meta.instanceUrl);
|
||||
if (rawInstance === undefined) {
|
||||
throw new ConnectorError("authorize needs fields.instanceUrl", PROVIDER);
|
||||
}
|
||||
const instanceUrl = normalizeInstanceUrl(rawInstance);
|
||||
const host = new URL(instanceUrl).host;
|
||||
const connectionId = input.connectionId ?? str(meta.connectionId) ?? `${PROVIDER}:${input.workspaceId}:${host}`;
|
||||
const fields: Record<string, string> = { instanceUrl };
|
||||
const env = options.env ?? {};
|
||||
const configuredInstance = env.MASTODON_INSTANCE_URL;
|
||||
const useConfigured = configuredInstance !== undefined && normalizeInstanceUrl(configuredInstance) === instanceUrl;
|
||||
const fieldClientId = str(input.fields?.clientId) ?? str(meta.clientId) ?? (useConfigured ? str(env.MASTODON_CLIENT_ID) : undefined);
|
||||
if (fieldClientId !== undefined) fields.clientId = fieldClientId;
|
||||
const credentialId: CredentialRef = { kind: "credential-ref", id: connectionId };
|
||||
const connection: ConnectorConnection = {
|
||||
id: connectionId,
|
||||
workspaceId: input.workspaceId,
|
||||
provider: PROVIDER,
|
||||
channelId: str(meta.channelId) ?? connectionId,
|
||||
credentialId,
|
||||
fields,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const fieldSecret = str(input.fields?.clientSecret) ?? str(meta.clientSecret) ?? (useConfigured ? str(env.MASTODON_CLIENT_SECRET) : undefined);
|
||||
if (fieldClientId !== undefined && fieldSecret !== undefined) {
|
||||
await credentials.put(connection, SECRET.clientId, fieldClientId);
|
||||
await credentials.put(connection, SECRET.clientSecret, fieldSecret);
|
||||
}
|
||||
let client = await clientCredentials(connection);
|
||||
if (client === undefined) {
|
||||
const app = await form<MastodonApp>(`${instanceUrl}/api/v1/apps`, {
|
||||
client_name: options.clientName ?? "AnarchoPoster",
|
||||
redirect_uris: input.redirectUri,
|
||||
scopes: SCOPES.join(" "),
|
||||
...(options.website !== undefined ? { website: options.website } : {}),
|
||||
});
|
||||
await credentials.put(connection, SECRET.clientId, app.client_id);
|
||||
await credentials.put(connection, SECRET.clientSecret, app.client_secret);
|
||||
client = { clientId: app.client_id, clientSecret: app.client_secret };
|
||||
}
|
||||
|
||||
const code = str(meta.code);
|
||||
if (code === undefined) {
|
||||
const url = new URL(`${instanceUrl}/oauth/authorize`);
|
||||
url.searchParams.set("client_id", client.clientId);
|
||||
url.searchParams.set("redirect_uri", input.redirectUri);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", SCOPES.join(" "));
|
||||
url.searchParams.set("state", input.state);
|
||||
if (input.codeVerifier !== undefined) {
|
||||
url.searchParams.set("code_challenge", createHash("sha256").update(input.codeVerifier).digest("base64url"));
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
}
|
||||
throw new AuthorizationRedirectRequired(url.toString(), connectionId);
|
||||
}
|
||||
|
||||
const token = await form<MastodonToken>(`${instanceUrl}/oauth/token`, {
|
||||
grant_type: "authorization_code",
|
||||
client_id: client.clientId,
|
||||
client_secret: client.clientSecret,
|
||||
redirect_uri: input.redirectUri,
|
||||
code,
|
||||
scope: SCOPES.join(" "),
|
||||
...(input.codeVerifier !== undefined ? { code_verifier: input.codeVerifier } : {}),
|
||||
});
|
||||
await storeTokens(connection, token);
|
||||
accounts.delete(connection.id);
|
||||
const identity = identityOf(connection, await selfAccount(connection));
|
||||
return { connection, identity };
|
||||
},
|
||||
|
||||
async refreshCredentials(connection: ConnectorConnection): Promise<CredentialRef> {
|
||||
const refreshToken = await secretOrUndefined(connection, SECRET.refreshToken);
|
||||
if (refreshToken === undefined) {
|
||||
throw new AuthError(
|
||||
"no refresh token stored — this instance issued a non-expiring token; reauthorize if revoked",
|
||||
PROVIDER,
|
||||
false,
|
||||
);
|
||||
}
|
||||
const client = await clientCredentials(connection);
|
||||
if (client === undefined) throw new AuthError("no client credentials for refresh", PROVIDER, false);
|
||||
const token = await form<MastodonToken>(`${instanceOf(connection)}/oauth/token`, {
|
||||
grant_type: "refresh_token",
|
||||
client_id: client.clientId,
|
||||
client_secret: client.clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
scope: SCOPES.join(" "),
|
||||
});
|
||||
await storeTokens(connection, token);
|
||||
return connection.credentialId;
|
||||
},
|
||||
|
||||
async revokeCredentials(connection: ConnectorConnection): Promise<void> {
|
||||
const client = await clientCredentials(connection);
|
||||
if (client === undefined) throw new AuthError("no client credentials for revoke", PROVIDER, false);
|
||||
await form<Record<string, never>>(`${instanceOf(connection)}/oauth/revoke`, {
|
||||
client_id: client.clientId,
|
||||
client_secret: client.clientSecret,
|
||||
token: await credentials.getAccessToken(connection),
|
||||
});
|
||||
accounts.delete(connection.id);
|
||||
},
|
||||
|
||||
async getIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
|
||||
return identityOf(connection, await selfAccount(connection));
|
||||
},
|
||||
|
||||
async publish(connection: ConnectorConnection, post: NormalizedPost): Promise<Publication> {
|
||||
const meta = post.providerMetadata;
|
||||
const preUploaded = Array.isArray(meta.mediaIds)
|
||||
? meta.mediaIds.filter((id): id is string => typeof id === "string")
|
||||
: [];
|
||||
const altText =
|
||||
typeof meta.altText === "object" && meta.altText !== null
|
||||
? (meta.altText as Record<string, unknown>)
|
||||
: {};
|
||||
const mediaIds = [...preUploaded, ...(await uploadAll(connection, post.mediaAssetIds, altText))];
|
||||
if (mediaIds.length > MAX_MEDIA) {
|
||||
throw new ConnectorError(`at most ${MAX_MEDIA} media attachments per status`, PROVIDER);
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = { status: post.text };
|
||||
if (mediaIds.length > 0) body.media_ids = mediaIds;
|
||||
const visibility = VISIBILITY[post.visibility];
|
||||
if (visibility !== undefined) body.visibility = visibility;
|
||||
if (post.scheduledAt !== undefined && post.scheduledAt.getTime() - Date.now() >= MIN_SCHEDULE_LEAD_MS) {
|
||||
body.scheduled_at = post.scheduledAt.toISOString();
|
||||
}
|
||||
const inReplyTo = str(meta.inReplyToId);
|
||||
if (inReplyTo !== undefined) body.in_reply_to_id = inReplyTo;
|
||||
const language = str(meta.language);
|
||||
if (language !== undefined) body.language = language;
|
||||
if (typeof meta.sensitive === "boolean") body.sensitive = meta.sensitive;
|
||||
const spoiler = str(meta.spoilerText);
|
||||
if (spoiler !== undefined) body.spoiler_text = spoiler;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const idempotencyKey = str(meta.idempotencyKey);
|
||||
if (idempotencyKey !== undefined) headers["idempotency-key"] = idempotencyKey;
|
||||
|
||||
const { data } = await api<MastodonStatus | MastodonScheduledStatus>(
|
||||
connection,
|
||||
"POST",
|
||||
"/api/v1/statuses",
|
||||
body,
|
||||
headers,
|
||||
);
|
||||
// Draft identity is owned by the caller (the publishing runtime keys
|
||||
// receipts by idempotency key); the returned id/draftId are local only.
|
||||
const receipt = idempotencyKey === undefined ? {} : { idempotencyKey };
|
||||
if (isScheduled(data)) {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
workspaceId: connection.workspaceId,
|
||||
draftId: "direct",
|
||||
connectionId: connection.id,
|
||||
remoteId: `scheduled:${data.id}`,
|
||||
publishedAt: new Date(data.scheduled_at),
|
||||
providerMetadata: {
|
||||
outcome: "provider_scheduled",
|
||||
scheduled: true,
|
||||
scheduledStatusId: data.id,
|
||||
scheduledAt: data.scheduled_at,
|
||||
...receipt,
|
||||
},
|
||||
};
|
||||
}
|
||||
return toPublication(connection, data, "direct", { outcome: "published", ...receipt });
|
||||
},
|
||||
|
||||
async deletePublication(connection: ConnectorConnection, remoteId: string): Promise<void> {
|
||||
const path = remoteId.startsWith("scheduled:")
|
||||
? `/api/v1/scheduled_statuses/${encodeURIComponent(remoteId.slice("scheduled:".length))}`
|
||||
: `/api/v1/statuses/${encodeURIComponent(remoteId)}`;
|
||||
try {
|
||||
await api(connection, "DELETE", path);
|
||||
} catch (error) {
|
||||
// Already gone counts as deleted — deletion is idempotent.
|
||||
const status = error instanceof ConnectorError ? (error.cause as { status?: number } | undefined)?.status : undefined;
|
||||
if (status !== 404) throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async uploadMedia(connection: ConnectorConnection, media: MediaAsset): Promise<RemoteMedia> {
|
||||
return uploadAsset(connection, media);
|
||||
},
|
||||
|
||||
async getFeed(
|
||||
connection: ConnectorConnection,
|
||||
request: { kind: "home" | "mentions" | "search"; query?: string; cursor?: string; limit: number },
|
||||
): Promise<Page<FeedItem>> {
|
||||
const limit = Math.max(1, Math.min(40, request.limit));
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (request.cursor !== undefined) params.set("max_id", request.cursor);
|
||||
|
||||
if (request.kind === "home") {
|
||||
return statusPage(connection, "/api/v1/timelines/home", params, limit);
|
||||
}
|
||||
if (request.kind === "search") {
|
||||
const tag = (request.query ?? "").trim().replace(/^#/, "");
|
||||
if (!/^[\p{L}\p{N}_]+$/u.test(tag)) {
|
||||
throw new ConnectorError("search supports a single hashtag only", PROVIDER);
|
||||
}
|
||||
return statusPage(connection, `/api/v1/timelines/tag/${encodeURIComponent(tag)}`, params, limit);
|
||||
}
|
||||
params.append("types[]", "mention");
|
||||
const { data, response } = await api<MastodonNotification[]>(
|
||||
connection,
|
||||
"GET",
|
||||
`/api/v1/notifications?${params}`,
|
||||
);
|
||||
const items = data
|
||||
.map((n) => n.status)
|
||||
.filter((s): s is MastodonStatus => s !== null && s !== undefined)
|
||||
.map(toFeedItem);
|
||||
const fallback = data.length >= limit ? data[data.length - 1]?.id : undefined;
|
||||
return pageOf(items, nextMaxId(response) ?? fallback);
|
||||
},
|
||||
|
||||
async getComments(
|
||||
connection: ConnectorConnection,
|
||||
request: { onRemoteId: string; cursor?: string; limit: number },
|
||||
): Promise<Page<Comment>> {
|
||||
const { data } = await api<MastodonContext>(
|
||||
connection,
|
||||
"GET",
|
||||
`/api/v1/statuses/${encodeURIComponent(request.onRemoteId)}/context`,
|
||||
);
|
||||
// /context is not paginated; the cursor is an offset into descendants.
|
||||
const start = request.cursor !== undefined ? Number(request.cursor) : 0;
|
||||
const slice = data.descendants.slice(start, start + request.limit);
|
||||
const comments = slice.map(
|
||||
(s): Comment => ({
|
||||
id: s.id,
|
||||
provider: PROVIDER,
|
||||
remoteId: s.id,
|
||||
onRemoteId: s.in_reply_to_id ?? request.onRemoteId,
|
||||
authorHandle: s.account.acct,
|
||||
text: htmlToText(s.content),
|
||||
createdAt: new Date(s.created_at),
|
||||
}),
|
||||
);
|
||||
const next = start + request.limit;
|
||||
return pageOf(comments, next < data.descendants.length ? String(next) : undefined);
|
||||
},
|
||||
|
||||
/**
|
||||
* Replies inherit the target's visibility (a reply to a DM stays direct)
|
||||
* and mention the target's author so they are notified.
|
||||
*/
|
||||
async reply(
|
||||
connection: ConnectorConnection,
|
||||
request: { onRemoteId: string; text: string },
|
||||
): Promise<Publication> {
|
||||
const target = await getStatus(connection, request.onRemoteId);
|
||||
const self = await selfAccount(connection);
|
||||
const handle = `@${target.account.acct}`;
|
||||
const needsMention =
|
||||
target.account.id !== self.id && !request.text.toLowerCase().includes(handle.toLowerCase());
|
||||
const { data } = await api<MastodonStatus>(connection, "POST", "/api/v1/statuses", {
|
||||
status: needsMention ? `${handle} ${request.text}` : request.text,
|
||||
in_reply_to_id: target.id,
|
||||
visibility: target.visibility,
|
||||
});
|
||||
return toPublication(connection, data, "reply", { inReplyTo: target.id });
|
||||
},
|
||||
|
||||
async amplify(
|
||||
connection: ConnectorConnection,
|
||||
request: {
|
||||
kind: "repost" | "quote" | "share";
|
||||
targetRemoteId: string;
|
||||
commentary?: string;
|
||||
quoteAssetIds?: string[];
|
||||
},
|
||||
): Promise<Publication> {
|
||||
const target = encodeURIComponent(request.targetRemoteId);
|
||||
if (request.kind === "repost") {
|
||||
const { data } = await api<MastodonStatus>(connection, "POST", `/api/v1/statuses/${target}/reblog`);
|
||||
return toPublication(connection, data, "amplify:repost", {
|
||||
amplifies: request.targetRemoteId,
|
||||
kind: "repost",
|
||||
});
|
||||
}
|
||||
if (request.kind === "quote") {
|
||||
const mediaIds = await uploadAll(connection, request.quoteAssetIds ?? []);
|
||||
const body: Record<string, unknown> = {
|
||||
status: request.commentary ?? "",
|
||||
quoted_status_id: request.targetRemoteId,
|
||||
};
|
||||
if (mediaIds.length > 0) body.media_ids = mediaIds;
|
||||
const { data } = await api<MastodonStatus>(connection, "POST", "/api/v1/statuses", body);
|
||||
if (data.quote === undefined || data.quote === null) {
|
||||
// Pre-4.5 server dropped quoted_status_id and posted a plain status.
|
||||
await deleteStatus(connection, data.id);
|
||||
throw new CapabilityNotSupportedError(PROVIDER, "amplifyQuote (instance older than 4.5)");
|
||||
}
|
||||
return toPublication(connection, data, "amplify:quote", {
|
||||
amplifies: request.targetRemoteId,
|
||||
kind: "quote",
|
||||
quoteState: data.quote.state,
|
||||
});
|
||||
}
|
||||
throw new CapabilityNotSupportedError(PROVIDER, "amplifyShare");
|
||||
},
|
||||
|
||||
/**
|
||||
* Reblog wrappers are undone via POST /:originalId/unreblog (the endpoint
|
||||
* takes the original's id, not the wrapper's); quotes are deleted.
|
||||
*/
|
||||
async unamplify(connection: ConnectorConnection, remoteId: string): Promise<void> {
|
||||
const status = await getStatus(connection, remoteId);
|
||||
if (status.reblog !== null) {
|
||||
await api(connection, "POST", `/api/v1/statuses/${encodeURIComponent(status.reblog.id)}/unreblog`);
|
||||
return;
|
||||
}
|
||||
await deleteStatus(connection, status.id);
|
||||
},
|
||||
|
||||
async getMessages(
|
||||
connection: ConnectorConnection,
|
||||
request: { conversationRemoteId?: string; cursor?: string; limit: number },
|
||||
): Promise<Page<DirectMessage>> {
|
||||
const self = await selfAccount(connection);
|
||||
const limit = Math.max(1, Math.min(40, request.limit));
|
||||
|
||||
if (request.conversationRemoteId === undefined) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (request.cursor !== undefined) params.set("max_id", request.cursor);
|
||||
const { data, response } = await api<MastodonConversation[]>(
|
||||
connection,
|
||||
"GET",
|
||||
`/api/v1/conversations?${params}`,
|
||||
);
|
||||
const items = data
|
||||
.filter((c) => c.last_status !== null)
|
||||
.map((c) => messageOf(c.id, c.last_status as MastodonStatus, self, c.accounts));
|
||||
// Conversation pagination ids are internal — only the Link header is authoritative.
|
||||
return pageOf(items, nextMaxId(response));
|
||||
}
|
||||
|
||||
// No GET /conversations/:id exists: find the conversation, then read its thread.
|
||||
let found: MastodonConversation | undefined;
|
||||
let maxId: string | undefined;
|
||||
for (let page = 0; page < CONVERSATION_SCAN_PAGES && found === undefined; page += 1) {
|
||||
const params = new URLSearchParams({ limit: "40" });
|
||||
if (maxId !== undefined) params.set("max_id", maxId);
|
||||
const { data, response } = await api<MastodonConversation[]>(
|
||||
connection,
|
||||
"GET",
|
||||
`/api/v1/conversations?${params}`,
|
||||
);
|
||||
found = data.find((c) => c.id === request.conversationRemoteId);
|
||||
maxId = nextMaxId(response);
|
||||
if (maxId === undefined) break;
|
||||
}
|
||||
if (found?.last_status === null || found?.last_status === undefined) {
|
||||
throw new ConnectorError(`conversation ${request.conversationRemoteId} not found`, PROVIDER);
|
||||
}
|
||||
const conversation = found;
|
||||
const last = found.last_status;
|
||||
const { data: context } = await api<MastodonContext>(
|
||||
connection,
|
||||
"GET",
|
||||
`/api/v1/statuses/${encodeURIComponent(last.id)}/context`,
|
||||
);
|
||||
const thread = [...context.ancestors, last, ...context.descendants].filter((s) => s.visibility === "direct");
|
||||
const start = request.cursor !== undefined ? Number(request.cursor) : 0;
|
||||
const items = thread
|
||||
.slice(start, start + limit)
|
||||
.map((s) => messageOf(conversation.id, s, self, conversation.accounts));
|
||||
const next = start + limit;
|
||||
return pageOf(items, next < thread.length ? String(next) : undefined);
|
||||
},
|
||||
};
|
||||
return connector;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue