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