T-HUB/packages/connector-threads/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

724 lines
24 KiB
TypeScript

/**
* Threads connector — Threads API on graph.threads.net.
*
* Publishing is URL-backed and two-step: create a container, wait for video
* processing when applicable, then publish the container. `mediaAssetIds`
* therefore need to be public URLs, or callers can provide `resolveMediaUrl`.
* Reads deliberately expose only the connected account's own threads; the API
* endpoint is not a home timeline. Threads does not document webhooks.
*/
import { randomUUID } from "node:crypto";
import type {
ConnectorCapabilities,
ConnectorConnection,
FeedItem,
NormalizedIdentity,
NormalizedPost,
NormalizedVideoPublication,
Page,
Publication,
VideoProcessingStatus,
} from "@tactical-media-broadcast-hub/core";
import {
assertValidManifest,
AuthError,
CapabilityNotSupportedError,
ConnectorError,
InMemoryCredentials,
pageOf,
RateLimitedHttpClient,
RateLimitError,
resolveRatePolicy,
type ConnectorManifest,
type CredentialAccessor,
type PlatformConnector,
} from "@tactical-media-broadcast-hub/connector-sdk";
const PROVIDER = "threads";
const API_ROOT = "https://graph.threads.net";
const API_BASE = `${API_ROOT}/v1.0`;
const TEXT_LIMIT = 500;
const FEED_FIELDS = "id,text,timestamp,permalink,reply_count";
const INSIGHT_METRICS = "views,likes,replies,reposts,quotes,followers_count";
const CAPABILITIES: ConnectorCapabilities = {
publishText: true,
publishImages: true,
publishCarousel: true,
publishVideo: true,
replies: true,
feed: true,
amplifyRepost: true,
amplifyQuote: true,
analytics: true,
directMessages: false,
webhooks: false,
comments: false,
deletePublication: true,
};
type MediaKind = "image" | "video";
export interface ThreadsMediaInput {
url: string;
kind: MediaKind;
}
export interface ThreadsConnectorOptions {
fetchImpl?: typeof fetch;
/** Test/deployment override; the documented default remains 0.5 req/s. */
requestsPerSecond?: number;
/** Secret store. Connection fields remain a development/test fallback. */
credentials?: CredentialAccessor;
/** Resolve a AnarchoPoster asset id to a publicly fetchable image/video URL. */
resolveMediaUrl?: (assetId: string) => Promise<string | ThreadsMediaInput>;
/** Video-container polling delay. Default 10 seconds. */
pollIntervalMs?: number;
/** Maximum status reads while publishing a video. Default 30. */
maxPollAttempts?: number;
/** Polling sleep override for tests. */
sleep?: (ms: number) => Promise<void>;
}
interface GraphErrorBody {
error?: {
message?: string;
code?: number;
error_subcode?: number;
error_user_msg?: string;
};
}
interface GraphIdResponse {
id?: string;
success?: boolean;
}
interface ThreadsIdentity {
id: string;
username?: string;
threads_profile_picture_url?: string;
}
interface ThreadsContainer {
id: string;
status?: string;
status_code?: string;
error_message?: string;
}
interface ThreadsPost {
id: string;
text?: string;
timestamp?: string;
permalink?: string;
reply_count?: number;
}
interface GraphPaging {
cursors?: { before?: string; after?: string };
next?: string;
}
interface GraphList<T> {
data: T[];
paging?: GraphPaging;
}
interface ThreadsInsight {
name: string;
period?: string;
values?: { value: number | Record<string, number>; end_time?: string }[];
total_value?: { value: number | Record<string, number> };
}
interface RefreshedToken {
access_token: string;
token_type?: string;
expires_in?: number;
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function assertText(text: string, allowEmpty: boolean): void {
const length = [...text].length;
if (!allowEmpty && length === 0) {
throw new ConnectorError(`${PROVIDER}: text cannot be empty`, PROVIDER);
}
if (length > TEXT_LIMIT) {
throw new ConnectorError(`${PROVIDER}: text exceeds ${TEXT_LIMIT} characters`, PROVIDER);
}
}
function inferredKind(value: string): MediaKind {
const pathname = (() => {
try {
return new URL(value).pathname;
} catch {
return value;
}
})();
return /\.(?:mp4|mov|m4v|webm)$/i.test(pathname) ? "video" : "image";
}
/** Map Threads container states onto the SDK video state machine. */
export function mapThreadsContainerStatus(
remoteId: string,
container: ThreadsContainer,
): VideoProcessingStatus {
const status = (container.status_code ?? container.status ?? "IN_PROGRESS").toUpperCase();
if (status === "FINISHED" || status === "PUBLISHED") {
return { remoteId, state: "ready", progressPercent: 100 };
}
if (status === "ERROR" || status === "FAILED" || status === "EXPIRED") {
return {
remoteId,
state: "failed",
error: container.error_message ?? `container ${status.toLowerCase()}`,
};
}
if (status === "UPLOADING") return { remoteId, state: "uploading" };
return { remoteId, state: "processing" };
}
export function createThreadsConnector(options: ThreadsConnectorOptions = {}): PlatformConnector {
const ratePolicy = resolveRatePolicy(
PROVIDER,
{ dailyPostCap: 250, requestsPerSecond: 0.5 },
options.requestsPerSecond === undefined
? undefined
: { requestsPerSecond: options.requestsPerSecond },
);
const client = new RateLimitedHttpClient({
requestsPerSecond: ratePolicy.requestsPerSecond,
fetchImpl: options.fetchImpl,
});
const credentials = options.credentials ?? new InMemoryCredentials();
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
const pollIntervalMs = options.pollIntervalMs ?? 10_000;
const maxPollAttempts = options.maxPollAttempts ?? 30;
const handles = new Map<string, string>();
const manifest: ConnectorManifest = {
id: PROVIDER,
displayName: "Threads",
capabilities: CAPABILITIES,
connectionFields: [
{
name: "accessToken",
label: "Long-lived access token",
required: true,
secret: true,
},
{
name: "threadsUserId",
label: "Threads user ID",
required: true,
secret: false,
pattern: "^[0-9]+$",
},
],
oauth: {
kind: "oauth2-redirect",
authorizeUrlTemplate: "https://threads.net/oauth/authorize",
scopes: [
"threads_basic",
"threads_content_publish",
"threads_manage_replies",
"threads_manage_insights",
],
},
docsUrl: "https://developers.facebook.com/docs/threads/",
};
function userIdOf(connection: ConnectorConnection): string {
const id = nonEmptyString(connection.fields["threadsUserId"]);
if (id === undefined) {
throw new ConnectorError(
`${PROVIDER}: connection ${connection.id} has no threadsUserId`,
PROVIDER,
);
}
return id;
}
async function accessTokenOf(connection: ConnectorConnection): Promise<string> {
try {
return await credentials.getAccessToken(connection);
} catch {
const token = nonEmptyString(connection.fields["accessToken"]);
if (token !== undefined) return token;
throw new AuthError(
`${PROVIDER}: no access token for connection ${connection.id}`,
PROVIDER,
false,
);
}
}
function parseJson(text: string): unknown {
if (text.length === 0) return {};
try {
return JSON.parse(text) as unknown;
} catch (cause) {
throw new ConnectorError(`${PROVIDER}: Graph API returned non-JSON`, PROVIDER, cause);
}
}
function graphError(
response: Response,
method: string,
url: URL,
body: unknown,
): Error {
const graph = body as GraphErrorBody;
const error = graph.error;
const detail = error?.error_user_msg ?? error?.message ?? "Graph API request failed";
const message = `${PROVIDER}: HTTP ${response.status} ${method} ${url.pathname}: ${detail}`;
const code = error?.code;
if (response.status === 429 || code === 4 || code === 17 || code === 32 || code === 613) {
return new RateLimitError(PROVIDER);
}
if (response.status === 401 || code === 190) {
return new AuthError(message, PROVIDER, false);
}
if (response.status === 403 || code === 10 || (code !== undefined && code >= 200 && code < 300)) {
return new AuthError(`${message} (missing Threads permission?)`, PROVIDER, false);
}
return new ConnectorError(message, PROVIDER, {
status: response.status,
code,
subcode: error?.error_subcode,
});
}
async function graph<T>(
connection: ConnectorConnection,
method: "GET" | "POST" | "DELETE",
path: string,
params: Record<string, string> = {},
): Promise<T> {
const url = new URL(`${API_BASE}${path}`);
const headers: Record<string, string> = {
accept: "application/json",
authorization: `Bearer ${await accessTokenOf(connection)}`,
};
const init: RequestInit = { method, headers };
if (method === "POST") {
headers["content-type"] = "application/x-www-form-urlencoded";
init.body = new URLSearchParams(params).toString();
} else {
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
}
const response = await client.request(PROVIDER, url.toString(), init);
const body = parseJson(await response.text());
if (!response.ok) throw graphError(response, method, url, body);
return body as T;
}
async function createContainer(
connection: ConnectorConnection,
params: Record<string, string>,
): Promise<string> {
const result = await graph<GraphIdResponse>(
connection,
"POST",
`/${userIdOf(connection)}/threads`,
params,
);
if (result.id === undefined) {
throw new ConnectorError(`${PROVIDER}: container creation returned no id`, PROVIDER);
}
return result.id;
}
async function containerStatus(
connection: ConnectorConnection,
containerId: string,
): Promise<ThreadsContainer> {
return graph<ThreadsContainer>(connection, "GET", `/${encodeURIComponent(containerId)}`, {
fields: "id,status,error_message",
});
}
async function awaitContainer(
connection: ConnectorConnection,
containerId: string,
): Promise<void> {
for (let attempt = 0; attempt < maxPollAttempts; attempt += 1) {
const status = mapThreadsContainerStatus(
containerId,
await containerStatus(connection, containerId),
);
if (status.state === "ready") return;
if (status.state === "failed") {
throw new ConnectorError(
`${PROVIDER}: container ${containerId} failed: ${status.error ?? "unknown error"}`,
PROVIDER,
);
}
if (attempt + 1 < maxPollAttempts) await sleep(pollIntervalMs);
}
throw new ConnectorError(
`${PROVIDER}: container ${containerId} still processing after ${maxPollAttempts} polls`,
PROVIDER,
);
}
function publication(
connection: ConnectorConnection,
remoteId: string,
draftId: string,
providerMetadata: Record<string, unknown>,
): Publication {
return {
id: randomUUID(),
workspaceId: connection.workspaceId,
draftId,
connectionId: connection.id,
remoteId,
publishedAt: new Date(),
providerMetadata,
};
}
async function publishContainer(
connection: ConnectorConnection,
creationId: string,
draftId: string,
providerMetadata: Record<string, unknown>,
): Promise<Publication> {
const result = await graph<GraphIdResponse>(
connection,
"POST",
`/${userIdOf(connection)}/threads_publish`,
{ creation_id: creationId },
);
if (result.id === undefined) {
throw new ConnectorError(`${PROVIDER}: publish returned no media id`, PROVIDER);
}
return publication(connection, result.id, draftId, {
creationId,
dailyPostCap: ratePolicy.dailyPostCap,
...providerMetadata,
});
}
function metadataMedia(metadata: Record<string, unknown>): ThreadsMediaInput[] {
if (!Array.isArray(metadata["media"])) return [];
const media: ThreadsMediaInput[] = [];
for (const entry of metadata["media"] as unknown[]) {
if (typeof entry !== "object" || entry === null) continue;
const item = entry as Record<string, unknown>;
const url = nonEmptyString(item["url"]);
const kind = item["kind"] === "image" || item["kind"] === "video" ? item["kind"] : undefined;
if (url !== undefined && kind !== undefined) media.push({ url, kind });
}
return media;
}
async function resolveAsset(assetId: string): Promise<ThreadsMediaInput> {
const resolved = options.resolveMediaUrl === undefined
? assetId
: await options.resolveMediaUrl(assetId);
if (typeof resolved === "string") return { url: resolved, kind: inferredKind(resolved) };
return resolved;
}
async function mediaFor(post: NormalizedPost): Promise<ThreadsMediaInput[]> {
const media = metadataMedia(post.providerMetadata);
for (const assetId of post.mediaAssetIds) media.push(await resolveAsset(assetId));
return media;
}
async function createTextPublication(
connection: ConnectorConnection,
text: string,
params: Record<string, string>,
draftId: string,
metadata: Record<string, unknown>,
allowEmpty = false,
): Promise<Publication> {
assertText(text, allowEmpty);
const containerParams: Record<string, string> = {
media_type: "TEXT",
...params,
};
if (text.length > 0) containerParams.text = text;
const creationId = await createContainer(connection, containerParams);
return publishContainer(connection, creationId, draftId, metadata);
}
return {
manifest: () => {
assertValidManifest(manifest);
return manifest;
},
async authorize() {
throw new ConnectorError(
`${PROVIDER}: start OAuth at https://threads.net/oauth/authorize, exchange the code at ${API_ROOT}/oauth/access_token, then store accessToken and threadsUserId`,
PROVIDER,
);
},
async refreshCredentials(connection: ConnectorConnection) {
const url = new URL(`${API_ROOT}/refresh_access_token`);
url.searchParams.set("grant_type", "th_refresh_token");
url.searchParams.set("access_token", await accessTokenOf(connection));
const response = await client.request(PROVIDER, url.toString(), {
headers: { accept: "application/json" },
});
const body = parseJson(await response.text());
if (!response.ok) throw graphError(response, "GET", url, body);
const token = body as RefreshedToken;
if (nonEmptyString(token.access_token) === undefined) {
throw new AuthError(`${PROVIDER}: token refresh returned no access token`, PROVIDER, false);
}
await credentials.put(connection, "access_token", token.access_token);
return connection.credentialId;
},
async getIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
const me = await graph<ThreadsIdentity>(connection, "GET", "/me", {
fields: "id,username,threads_profile_picture_url",
});
const username = me.username ?? me.id;
const handle = `@${username}`;
handles.set(connection.id, handle);
const identity: NormalizedIdentity = {
provider: PROVIDER,
remoteId: me.id,
handle,
};
if (me.username !== undefined) identity.displayName = me.username;
if (me.threads_profile_picture_url !== undefined) {
identity.avatarUrl = me.threads_profile_picture_url;
}
return identity;
},
async publish(connection: ConnectorConnection, post: NormalizedPost): Promise<Publication> {
const draftId = nonEmptyString(post.providerMetadata["draftId"]) ?? "direct";
const existing = nonEmptyString(post.providerMetadata["creationId"]);
if (existing !== undefined) {
await awaitContainer(connection, existing);
return publishContainer(connection, existing, draftId, { kind: "existing-container" });
}
assertText(post.text, post.mediaAssetIds.length > 0 || metadataMedia(post.providerMetadata).length > 0);
const media = await mediaFor(post);
if (media.length === 0) {
return createTextPublication(connection, post.text, {}, draftId, { kind: "text" });
}
const first = media[0];
if (media.length === 1 && first !== undefined) {
const params: Record<string, string> = first.kind === "image"
? { media_type: "IMAGE", image_url: first.url }
: { media_type: "VIDEO", video_url: first.url };
if (post.text.length > 0) params.text = post.text;
const creationId = await createContainer(connection, params);
if (first.kind === "video") await awaitContainer(connection, creationId);
return publishContainer(connection, creationId, draftId, { kind: first.kind });
}
const children: string[] = [];
for (const item of media) {
const params: Record<string, string> = item.kind === "image"
? { media_type: "IMAGE", image_url: item.url, is_carousel_item: "true" }
: { media_type: "VIDEO", video_url: item.url, is_carousel_item: "true" };
const childId = await createContainer(connection, params);
if (item.kind === "video") await awaitContainer(connection, childId);
children.push(childId);
}
const parentParams: Record<string, string> = {
media_type: "CAROUSEL",
children: children.join(","),
};
if (post.text.length > 0) parentParams.text = post.text;
const parentId = await createContainer(connection, parentParams);
return publishContainer(connection, parentId, draftId, { kind: "carousel", children });
},
async uploadVideo(
connection: ConnectorConnection,
video: NormalizedVideoPublication,
): Promise<{ remoteId: string }> {
const explicitUrl = nonEmptyString(video.providerMetadata["videoUrl"]);
const resolved = explicitUrl === undefined
? await resolveAsset(video.videoAssetId)
: { url: explicitUrl, kind: "video" as const };
const text = [video.title, video.description].filter((part): part is string => !!part).join("\n\n");
assertText(text, true);
const params: Record<string, string> = {
media_type: "VIDEO",
video_url: resolved.url,
};
if (text.length > 0) params.text = text;
return { remoteId: await createContainer(connection, params) };
},
async getVideoStatus(
connection: ConnectorConnection,
remoteId: string,
): Promise<VideoProcessingStatus> {
return mapThreadsContainerStatus(remoteId, await containerStatus(connection, remoteId));
},
async reply(
connection: ConnectorConnection,
request: { onRemoteId: string; text: string },
): Promise<Publication> {
return createTextPublication(
connection,
request.text,
{ reply_to_id: request.onRemoteId },
"reply",
{ kind: "reply", inReplyTo: request.onRemoteId },
);
},
async amplify(
connection: ConnectorConnection,
request: {
kind: "repost" | "quote" | "share";
targetRemoteId: string;
commentary?: string;
quoteAssetIds?: string[];
},
): Promise<Publication> {
if (request.kind === "share") {
throw new CapabilityNotSupportedError(PROVIDER, "amplifyShare");
}
if (request.kind === "repost") {
const result = await graph<GraphIdResponse>(
connection,
"POST",
`/${encodeURIComponent(request.targetRemoteId)}/repost`,
);
return publication(
connection,
result.id ?? request.targetRemoteId,
"amplify:repost",
{ kind: "repost", amplifies: request.targetRemoteId },
);
}
if ((request.quoteAssetIds?.length ?? 0) > 0) {
throw new CapabilityNotSupportedError(PROVIDER, "quote posts with media");
}
const commentary = request.commentary ?? "";
return createTextPublication(
connection,
commentary,
{ quote_post_id: request.targetRemoteId },
"amplify:quote",
{ kind: "quote", amplifies: request.targetRemoteId },
true,
);
},
async unamplify(connection: ConnectorConnection, remoteId: string): Promise<void> {
await graph(connection, "POST", `/${encodeURIComponent(remoteId)}/unrepost`);
},
async deletePublication(connection: ConnectorConnection, remoteId: string): Promise<void> {
try {
await graph(connection, "DELETE", `/${encodeURIComponent(remoteId)}`);
} catch (error) {
const status = error instanceof ConnectorError
? (error.cause as { status?: number } | undefined)?.status
: undefined;
if (status !== 404) throw error;
}
},
async getFeed(
connection: ConnectorConnection,
request: {
kind: "home" | "mentions" | "search";
query?: string;
cursor?: string;
limit: number;
},
): Promise<Page<FeedItem>> {
if (request.kind !== "home") {
throw new CapabilityNotSupportedError(
PROVIDER,
`feed:${request.kind} (Threads exposes only the connected account's own threads)`,
);
}
const params: Record<string, string> = {
fields: FEED_FIELDS,
limit: String(Math.max(1, Math.min(100, request.limit))),
};
if (request.cursor !== undefined) params.after = request.cursor;
const result = await graph<GraphList<ThreadsPost>>(
connection,
"GET",
`/${userIdOf(connection)}/threads`,
params,
);
const authorHandle = handles.get(connection.id) ?? `@${userIdOf(connection)}`;
const items = result.data.map(
(thread): FeedItem => ({
id: thread.id,
provider: PROVIDER,
remoteId: thread.id,
authorHandle,
text: thread.text ?? "",
mediaUrls: [],
createdAt: thread.timestamp === undefined ? new Date(0) : new Date(thread.timestamp),
metrics: { replies: thread.reply_count ?? 0 },
}),
);
const nextCursor = result.paging?.next === undefined
? undefined
: result.paging.cursors?.after;
return pageOf(items, nextCursor);
},
async getAnalytics(
connection: ConnectorConnection,
request: { subjectRemoteId?: string; from: Date; to: Date },
): Promise<{
values: Record<string, number>;
series: { at: Date; values: Record<string, number> }[];
}> {
if (request.subjectRemoteId !== undefined) {
throw new CapabilityNotSupportedError(
PROVIDER,
"per-publication analytics (this connector exposes account threads_insights)",
);
}
const result = await graph<GraphList<ThreadsInsight>>(
connection,
"GET",
`/${userIdOf(connection)}/threads_insights`,
{
metric: INSIGHT_METRICS,
since: String(Math.floor(request.from.getTime() / 1000)),
until: String(Math.floor(request.to.getTime() / 1000)),
},
);
const values: Record<string, number> = {};
const byTime = new Map<string, Record<string, number>>();
for (const insight of result.data) {
const total = insight.total_value?.value;
if (typeof total === "number") values[insight.name] = total;
for (const point of insight.values ?? []) {
if (point.end_time === undefined || typeof point.value !== "number") continue;
const at = byTime.get(point.end_time) ?? {};
at[insight.name] = point.value;
byTime.set(point.end_time, at);
}
}
const series = [...byTime.entries()]
.map(([at, pointValues]) => ({ at: new Date(at), values: pointValues }))
.sort((left, right) => left.at.getTime() - right.at.getTime());
return { values, series };
},
};
}