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:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View file

@ -0,0 +1,391 @@
import { describe, expect, it } from "vitest";
import type { ConnectorConnection, NormalizedPost } from "@tactical-media-broadcast-hub/core";
import {
connectorContractTests,
videoConnectorContractTests,
} from "@tactical-media-broadcast-hub/connector-sdk/testing";
import { createThreadsConnector, type ThreadsConnectorOptions } from "./index.js";
interface StubContainer {
id: string;
params: Record<string, string>;
}
interface StubCall {
method: string;
url: URL;
body: URLSearchParams;
authorization: string | null;
}
interface StubState {
calls: StubCall[];
containers: Map<string, StubContainer>;
nextContainer: number;
nextPublication: number;
}
const TOKEN = "THQVJstubbed-long-lived-token";
const THREADS_USER_ID = "10001234567890";
const connection: ConnectorConnection = {
id: "conn-threads",
workspaceId: "ws-1",
provider: "threads",
channelId: "threads-channel",
credentialId: { kind: "credential-ref", id: "cred-threads" },
fields: { accessToken: TOKEN, threadsUserId: THREADS_USER_ID },
createdAt: new Date(),
};
function state(): StubState {
return {
calls: [],
containers: new Map(),
nextContainer: 1,
nextPublication: 1,
};
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function graphError(message: string, code: number, status = 400): Response {
return json({ error: { message, code } }, status);
}
function stubGraph(stub: StubState): typeof fetch {
return async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
const href = typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const url = new URL(href);
const method = init?.method ?? "GET";
const body = new URLSearchParams(typeof init?.body === "string" ? init.body : "");
const authorization = new Headers(init?.headers).get("authorization");
stub.calls.push({ method, url, body, authorization });
if (url.pathname === "/refresh_access_token") {
return json({
access_token: "THQVJrefreshed-token",
token_type: "bearer",
expires_in: 5_184_000,
});
}
if (authorization !== `Bearer ${TOKEN}` && authorization !== "Bearer THQVJrefreshed-token") {
return graphError("Invalid OAuth access token.", 190, 401);
}
const parts = url.pathname.split("/").filter((part) => part.length > 0);
if (parts[0] === "v1.0") parts.shift();
const [node, edge] = parts;
if (node === "me" && method === "GET") {
return json({
id: THREADS_USER_ID,
username: "anarchoposter",
threads_profile_picture_url: "https://cdn.example.com/avatar.jpg",
});
}
if (node === THREADS_USER_ID && edge === "threads" && method === "POST") {
const id = `container-${stub.nextContainer++}`;
stub.containers.set(id, { id, params: Object.fromEntries(body) });
return json({ id });
}
if (node === THREADS_USER_ID && edge === "threads_publish" && method === "POST") {
const creationId = body.get("creation_id") ?? "";
if (!stub.containers.has(creationId)) return graphError("Unknown container", 100);
return json({ id: `thread-${stub.nextPublication++}` });
}
if (node === THREADS_USER_ID && edge === "threads" && method === "GET") {
return json({
data: [
{
id: "thread-feed-1",
text: "Own-thread read surface",
timestamp: "2026-10-05T12:00:00+0000",
permalink: "https://www.threads.net/@tactical-media-broadcast-hub/post/abc",
reply_count: 7,
},
],
paging: {
cursors: { before: "before-1", after: "after-1" },
next: "https://graph.threads.net/v1.0/next",
},
});
}
if (node === THREADS_USER_ID && edge === "threads_insights" && method === "GET") {
return json({
data: [
{
name: "views",
period: "day",
values: [
{ value: 10, end_time: "2026-10-01T07:00:00+0000" },
{ value: 12, end_time: "2026-10-02T07:00:00+0000" },
],
total_value: { value: 22 },
},
{ name: "likes", period: "day", total_value: { value: 5 } },
],
});
}
if (node !== undefined && stub.containers.has(node) && edge === undefined && method === "GET") {
return json({ id: node, status: "FINISHED" });
}
if (node !== undefined && edge === "repost" && method === "POST") {
return json({ id: `repost-${node}` });
}
if (node !== undefined && edge === "unrepost" && method === "POST") {
return json({ success: true });
}
if (node !== undefined && edge === undefined && method === "DELETE") {
return json({ success: true });
}
return graphError(`Unknown path ${url.pathname}`, 100, 404);
};
}
interface Harness {
state: StubState;
connector: ReturnType<typeof createThreadsConnector>;
}
function harness(extra: ThreadsConnectorOptions = {}): Harness {
const stub = state();
return {
state: stub,
connector: createThreadsConnector({
fetchImpl: stubGraph(stub),
requestsPerSecond: 100_000,
pollIntervalMs: 0,
maxPollAttempts: 2,
sleep: async () => undefined,
...extra,
}),
};
}
function post(
text: string,
mediaAssetIds: string[] = [],
providerMetadata: Record<string, unknown> = {},
): NormalizedPost {
return {
text,
mediaAssetIds,
visibility: "public",
providerMetadata,
};
}
const contractHarness = harness();
connectorContractTests(contractHarness.connector, { connection });
videoConnectorContractTests(contractHarness.connector, { connection });
describe("threads connector: manifest and identity", () => {
it("declares the OAuth scopes and honest read capabilities", async () => {
const { connector } = harness();
const manifest = connector.manifest();
expect(manifest.oauth).toEqual({
kind: "oauth2-redirect",
authorizeUrlTemplate: "https://threads.net/oauth/authorize",
scopes: [
"threads_basic",
"threads_content_publish",
"threads_manage_replies",
"threads_manage_insights",
],
});
expect(manifest.capabilities).toMatchObject({
feed: true,
replies: true,
comments: false,
directMessages: false,
webhooks: false,
});
const identity = await connector.getIdentity(connection);
expect(identity).toEqual({
provider: "threads",
remoteId: THREADS_USER_ID,
handle: "@anarchoposter",
displayName: "anarchoposter",
avatarUrl: "https://cdn.example.com/avatar.jpg",
});
});
it("rejects a missing token without making a request", async () => {
const { connector, state: stub } = harness();
const missing: ConnectorConnection = {
...connection,
fields: { threadsUserId: THREADS_USER_ID },
};
await expect(connector.getIdentity(missing)).rejects.toMatchObject({ name: "AuthError" });
expect(stub.calls).toHaveLength(0);
});
});
describe("threads connector: two-step publishing", () => {
it("creates and publishes a text container", async () => {
const { connector, state: stub } = harness();
const result = await connector.publish?.(connection, post("Hello Threads"));
expect(result?.remoteId).toBe("thread-1");
expect(result?.providerMetadata).toMatchObject({
creationId: "container-1",
kind: "text",
dailyPostCap: 250,
});
expect(stub.calls.map((call) => [call.method, call.url.pathname])).toEqual([
["POST", `/v1.0/${THREADS_USER_ID}/threads`],
["POST", `/v1.0/${THREADS_USER_ID}/threads_publish`],
]);
expect(stub.calls[0]?.body.get("media_type")).toBe("TEXT");
expect(stub.calls[0]?.body.get("text")).toBe("Hello Threads");
expect(stub.calls[1]?.body.get("creation_id")).toBe("container-1");
});
it("creates carousel children, a parent, then publishes the parent", async () => {
const { connector, state: stub } = harness();
const result = await connector.publish?.(
connection,
post("A carousel", [], {
media: [
{ url: "https://cdn.example.com/one.jpg", kind: "image" },
{ url: "https://cdn.example.com/two.jpg", kind: "image" },
],
}),
);
const createCalls = stub.calls.filter((call) => call.url.pathname.endsWith("/threads"));
expect(createCalls).toHaveLength(3);
expect(createCalls[0]?.body.get("is_carousel_item")).toBe("true");
expect(createCalls[1]?.body.get("is_carousel_item")).toBe("true");
expect(createCalls[2]?.body.get("media_type")).toBe("CAROUSEL");
expect(createCalls[2]?.body.get("children")).toBe("container-1,container-2");
expect(result?.providerMetadata).toMatchObject({
kind: "carousel",
children: ["container-1", "container-2"],
});
});
it("polls a video container before publishing it", async () => {
const { connector, state: stub } = harness();
const result = await connector.publish?.(
connection,
post("Video", ["https://cdn.example.com/video.mp4"]),
);
expect(result?.remoteId).toBe("thread-1");
expect(stub.calls.map((call) => call.method)).toEqual(["POST", "GET", "POST"]);
expect(stub.calls[0]?.body.get("media_type")).toBe("VIDEO");
expect(stub.calls[1]?.url.pathname).toBe("/v1.0/container-1");
});
it("enforces the 500-character text limit before the API call", async () => {
const { connector, state: stub } = harness();
await expect(connector.publish?.(connection, post("x".repeat(501)))).rejects.toThrow(
/500 characters/,
);
expect(stub.calls).toHaveLength(0);
});
});
describe("threads connector: replies and amplification", () => {
it("publishes replies through a TEXT container with reply_to_id", async () => {
const { connector, state: stub } = harness();
const result = await connector.reply?.(connection, {
onRemoteId: "thread-original",
text: "A reply",
});
expect(stub.calls[0]?.body.get("reply_to_id")).toBe("thread-original");
expect(result?.providerMetadata).toMatchObject({
kind: "reply",
inReplyTo: "thread-original",
});
});
it("uses the native repost and unrepost endpoints", async () => {
const { connector, state: stub } = harness();
const result = await connector.amplify?.(connection, {
kind: "repost",
targetRemoteId: "thread-original",
});
await connector.unamplify?.(connection, "thread-original");
expect(result?.remoteId).toBe("repost-thread-original");
expect(stub.calls.map((call) => call.url.pathname)).toEqual([
"/v1.0/thread-original/repost",
"/v1.0/thread-original/unrepost",
]);
});
it("publishes a quote through quote_post_id", async () => {
const { connector, state: stub } = harness();
const result = await connector.amplify?.(connection, {
kind: "quote",
targetRemoteId: "thread-original",
commentary: "Worth reading",
});
expect(stub.calls[0]?.body.get("quote_post_id")).toBe("thread-original");
expect(stub.calls[0]?.body.get("text")).toBe("Worth reading");
expect(result?.providerMetadata.kind).toBe("quote");
});
});
describe("threads connector: own feed and analytics", () => {
it("maps only the connected account's own threads", async () => {
const { connector, state: stub } = harness();
await connector.getIdentity(connection);
const feed = await connector.getFeed?.(connection, { kind: "home", limit: 25 });
expect(feed).toEqual({
items: [
{
id: "thread-feed-1",
provider: "threads",
remoteId: "thread-feed-1",
authorHandle: "@anarchoposter",
text: "Own-thread read surface",
mediaUrls: [],
createdAt: new Date("2026-10-05T12:00:00+0000"),
metrics: { replies: 7 },
},
],
nextCursor: "after-1",
});
const feedCall = stub.calls.find((call) => call.url.pathname.endsWith("/threads"));
expect(feedCall?.url.searchParams.get("fields")).toBe(
"id,text,timestamp,permalink,reply_count",
);
await expect(
connector.getFeed?.(connection, { kind: "search", query: "news", limit: 10 }),
).rejects.toThrow(/own threads/);
});
it("maps account insight totals and daily series", async () => {
const { connector, state: stub } = harness();
const analytics = await connector.getAnalytics?.(connection, {
from: new Date("2026-10-01T00:00:00Z"),
to: new Date("2026-10-03T00:00:00Z"),
});
expect(analytics?.values).toEqual({ views: 22, likes: 5 });
expect(analytics?.series).toEqual([
{ at: new Date("2026-10-01T07:00:00+0000"), values: { views: 10 } },
{ at: new Date("2026-10-02T07:00:00+0000"), values: { views: 12 } },
]);
const call = stub.calls[0];
expect(call?.url.pathname).toBe(`/v1.0/${THREADS_USER_ID}/threads_insights`);
expect(call?.url.searchParams.get("metric")).toContain("views");
});
});

View file

@ -0,0 +1,724 @@
/**
* 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 };
},
};
}