T-HUB/packages/connector-mastodon/src/index.ts
glass beadian aa5e262f3a 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/.
2026-10-10 11:56:42 +00:00

985 lines
38 KiB
TypeScript

/**
* 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;
}