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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue