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
391
packages/connector-threads/src/contract.test.ts
Normal file
391
packages/connector-threads/src/contract.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
724
packages/connector-threads/src/index.ts
Normal file
724
packages/connector-threads/src/index.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue