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/.
985 lines
38 KiB
TypeScript
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;
|
|
}
|