/** * 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:` 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; /** 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; /** 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; /** 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; providerMetadata?: Record; } /** * 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; } 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 = { public: "public", unlisted: "unlisted", followers: "private", private: "direct", provider_default: undefined, }; const ENTITIES: Record = { 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(//gi, "\n") .replace(/<\/p>\s*]*>/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(); 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 { 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( connection: ConnectorConnection, method: string, path: string, body?: Record | FormData, extraHeaders: Record = {}, ): Promise<{ data: T; response: Response }> { const token = await credentials.getAccessToken(connection); const headers: Record = { 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(url: string, params: Record): Promise { 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 { const cached = accounts.get(connection.id); if (cached !== undefined) return cached; const { data } = await api(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 = {}, ): 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 { 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(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 = {}, ): Promise { 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 { const { data } = await api(connection, "GET", `/api/v1/statuses/${encodeURIComponent(id)}`); return data; } async function deleteStatus(connection: ConnectorConnection, id: string): Promise { await api(connection, "DELETE", `/api/v1/statuses/${encodeURIComponent(id)}`); } async function statusPage( connection: ConnectorConnection, path: string, params: URLSearchParams, limit: number, ): Promise> { const { data, response } = await api(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 { 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 { 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 { 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 = { 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(`${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(`${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 { 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(`${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 { const client = await clientCredentials(connection); if (client === undefined) throw new AuthError("no client credentials for revoke", PROVIDER, false); await form>(`${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 { return identityOf(connection, await selfAccount(connection)); }, async publish(connection: ConnectorConnection, post: NormalizedPost): Promise { 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) : {}; 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 = { 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 = {}; const idempotencyKey = str(meta.idempotencyKey); if (idempotencyKey !== undefined) headers["idempotency-key"] = idempotencyKey; const { data } = await api( 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 { 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 { return uploadAsset(connection, media); }, async getFeed( connection: ConnectorConnection, request: { kind: "home" | "mentions" | "search"; query?: string; cursor?: string; limit: number }, ): Promise> { 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( 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> { const { data } = await api( 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 { 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(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 { const target = encodeURIComponent(request.targetRemoteId); if (request.kind === "repost") { const { data } = await api(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 = { status: request.commentary ?? "", quoted_status_id: request.targetRemoteId, }; if (mediaIds.length > 0) body.media_ids = mediaIds; const { data } = await api(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 { 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> { 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( 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( 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( 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; }