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
415
packages/connector-twitch/src/contract.test.ts
Normal file
415
packages/connector-twitch/src/contract.test.ts
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
import { createHmac } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
|
||||
import {
|
||||
connectorContractTests,
|
||||
livestreamConnectorContractTests,
|
||||
} from "@tactical-media-broadcast-hub/connector-sdk/testing";
|
||||
import { createTwitchConnector, type TwitchConnectorOptions } from "./index.js";
|
||||
|
||||
const CLIENT_ID = "client-id-for-tests";
|
||||
const TOKEN = "oauth-token-for-tests";
|
||||
const BROADCASTER_ID = "141981764";
|
||||
const WEBHOOK_SECRET = "eventsub-secret-for-tests";
|
||||
|
||||
const connection: ConnectorConnection = {
|
||||
id: "conn-twitch",
|
||||
workspaceId: "ws-1",
|
||||
provider: "twitch",
|
||||
channelId: BROADCASTER_ID,
|
||||
credentialId: { kind: "credential-ref", id: "cred-twitch" },
|
||||
fields: {
|
||||
clientId: CLIENT_ID,
|
||||
accessToken: TOKEN,
|
||||
broadcasterId: BROADCASTER_ID,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
interface StubCall {
|
||||
url: URL;
|
||||
init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
interface StubState {
|
||||
calls: StubCall[];
|
||||
live: boolean;
|
||||
subscriptions: number;
|
||||
}
|
||||
|
||||
const user = {
|
||||
id: BROADCASTER_ID,
|
||||
login: "tmbh_live",
|
||||
display_name: "TMBH Live",
|
||||
profile_image_url: "https://static.example/avatar.png",
|
||||
broadcaster_type: "partner",
|
||||
};
|
||||
|
||||
const stream = {
|
||||
id: "stream-9001",
|
||||
user_id: BROADCASTER_ID,
|
||||
user_login: user.login,
|
||||
user_name: user.display_name,
|
||||
type: "live",
|
||||
title: "Building in public",
|
||||
viewer_count: 321,
|
||||
started_at: "2026-10-07T10:00:00Z",
|
||||
game_name: "Software and Game Development",
|
||||
thumbnail_url: "https://static.example/live/{width}x{height}.jpg",
|
||||
};
|
||||
|
||||
function json(value: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function requestUrl(input: Parameters<typeof fetch>[0]): URL {
|
||||
if (typeof input === "string") return new URL(input);
|
||||
if (input instanceof URL) return input;
|
||||
return new URL(input.url);
|
||||
}
|
||||
|
||||
function header(init: RequestInit | undefined, name: string): string | null {
|
||||
return new Headers(init?.headers).get(name);
|
||||
}
|
||||
|
||||
function body(init: RequestInit | undefined): Record<string, unknown> {
|
||||
return JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stubHelix(state: StubState): typeof fetch {
|
||||
return async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
state.calls.push({ url, init });
|
||||
expect(header(init, "client-id")).toBe(CLIENT_ID);
|
||||
expect(header(init, "authorization")).toBe(`Bearer ${TOKEN}`);
|
||||
|
||||
if (url.pathname === "/helix/users") return json({ data: [user] });
|
||||
if (url.pathname === "/helix/streams") {
|
||||
return json({ data: state.live ? [stream] : [], pagination: {} });
|
||||
}
|
||||
if (url.pathname === "/helix/analytics/streams") {
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
started_at: "2026-10-06T10:00:00Z",
|
||||
metrics: { unique_viewers: 80, average_viewers: 25 },
|
||||
},
|
||||
{
|
||||
started_at: "2026-10-07T10:00:00Z",
|
||||
metrics: { unique_viewers: 100, average_viewers: 30 },
|
||||
},
|
||||
],
|
||||
pagination: {},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/helix/eventsub/subscriptions" && init?.method === "POST") {
|
||||
state.subscriptions += 1;
|
||||
return json({ data: [{ id: `eventsub-${state.subscriptions}`, status: "webhook_callback_verification_pending" }] });
|
||||
}
|
||||
return json({ error: "Not Found", status: 404, message: `${init?.method ?? "GET"} ${url.pathname}` }, 404);
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
state: StubState;
|
||||
connector: ReturnType<typeof createTwitchConnector>;
|
||||
}
|
||||
|
||||
function harness(overrides: TwitchConnectorOptions = {}): Harness {
|
||||
const state: StubState = { calls: [], live: true, subscriptions: 0 };
|
||||
return {
|
||||
state,
|
||||
connector: createTwitchConnector({
|
||||
fetchImpl: stubHelix(state),
|
||||
requestsPerSecond: 1000,
|
||||
sleep: async () => Promise.resolve(),
|
||||
webhookPublicUrl: "https://app.example.com/webhooks/twitch",
|
||||
webhookSecret: WEBHOOK_SECRET,
|
||||
env: {},
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function signedWebhook(
|
||||
payload: Record<string, unknown>,
|
||||
secret = WEBHOOK_SECRET,
|
||||
): { headers: Record<string, string>; body: string } {
|
||||
const raw = JSON.stringify(payload);
|
||||
const messageId = "evt-message-1";
|
||||
const timestamp = "2026-10-07T10:01:00Z";
|
||||
const signature = `sha256=${createHmac("sha256", secret)
|
||||
.update(messageId)
|
||||
.update(timestamp)
|
||||
.update(raw)
|
||||
.digest("hex")}`;
|
||||
return {
|
||||
headers: {
|
||||
"Twitch-Eventsub-Message-Id": messageId,
|
||||
"Twitch-Eventsub-Message-Timestamp": timestamp,
|
||||
"Twitch-Eventsub-Message-Signature": signature,
|
||||
"Twitch-Eventsub-Message-Type": "notification",
|
||||
},
|
||||
body: raw,
|
||||
};
|
||||
}
|
||||
|
||||
connectorContractTests(harness().connector, { connection });
|
||||
livestreamConnectorContractTests(harness().connector, { connection });
|
||||
|
||||
describe("twitch connector: manifest and identity", () => {
|
||||
it("declares the requested honest capability surface", () => {
|
||||
const manifest = harness().connector.manifest();
|
||||
expect(manifest).toMatchObject({
|
||||
id: "twitch",
|
||||
displayName: "Twitch",
|
||||
capabilities: {
|
||||
publishText: false,
|
||||
feed: false,
|
||||
comments: true,
|
||||
amplifyRepost: false,
|
||||
amplifyQuote: false,
|
||||
amplifyShare: false,
|
||||
livestreams: true,
|
||||
createLivestream: false,
|
||||
manageLivestream: false,
|
||||
livePlaybackSource: true,
|
||||
liveChatRead: true,
|
||||
liveChatModeration: true,
|
||||
analytics: true,
|
||||
livestreamAnalytics: true,
|
||||
webhooks: true,
|
||||
},
|
||||
});
|
||||
expect(manifest.connectionFields).toEqual([
|
||||
expect.objectContaining({ name: "clientId", required: true, secret: false }),
|
||||
expect.objectContaining({ name: "accessToken", required: true, secret: true }),
|
||||
expect.objectContaining({ name: "broadcasterId", required: true, secret: false }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps /helix/users and sends both required auth headers", async () => {
|
||||
const { state, connector } = harness();
|
||||
await expect(connector.getIdentity(connection)).resolves.toEqual({
|
||||
provider: "twitch",
|
||||
remoteId: BROADCASTER_ID,
|
||||
handle: "tmbh_live",
|
||||
displayName: "TMBH Live",
|
||||
avatarUrl: "https://static.example/avatar.png",
|
||||
});
|
||||
expect(state.calls[0]?.url.pathname).toBe("/helix/users");
|
||||
});
|
||||
});
|
||||
|
||||
describe("twitch connector: livestreams", () => {
|
||||
it("maps a live Helix stream and replaces the thumbnail template", async () => {
|
||||
const { state, connector } = harness();
|
||||
const result = await connector.getLiveStreams?.(connection, { limit: 10 });
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: stream.id,
|
||||
provider: "twitch",
|
||||
connectionId: connection.id,
|
||||
remoteId: stream.id,
|
||||
title: stream.title,
|
||||
state: "live",
|
||||
startedAt: new Date(stream.started_at),
|
||||
playbackUrls: ["https://static.example/live/1280x720.jpg"],
|
||||
},
|
||||
],
|
||||
});
|
||||
const call = state.calls[0];
|
||||
expect(call?.url.searchParams.get("user_id")).toBe(BROADCASTER_ID);
|
||||
expect(call?.url.searchParams.get("first")).toBe("10");
|
||||
});
|
||||
|
||||
it("maps empty stream data to offline (an empty page)", async () => {
|
||||
const { state, connector } = harness();
|
||||
state.live = false;
|
||||
await expect(connector.getLiveStreams?.(connection, { limit: 10 })).resolves.toEqual({ items: [] });
|
||||
});
|
||||
|
||||
it("resolves a channel live source by channelId through /helix/streams?user_login=", async () => {
|
||||
const { state, connector } = harness();
|
||||
await expect(
|
||||
connector.resolveLiveSource?.(connection, { channelId: "tmbh_live" }),
|
||||
).resolves.toEqual({
|
||||
playbackHlsUrl: "https://usher.ttvnw.net/api/channel/hls/tmbh_live.m3u8",
|
||||
live: true,
|
||||
});
|
||||
const call = state.calls[0];
|
||||
expect(call?.url.pathname).toBe("/helix/streams");
|
||||
expect(call?.url.searchParams.get("user_login")).toBe("tmbh_live");
|
||||
});
|
||||
|
||||
it("derives the login from an external URL (path and ?channel=)", async () => {
|
||||
const { state, connector } = harness();
|
||||
await expect(
|
||||
connector.resolveLiveSource?.(connection, { externalUrl: "https://www.twitch.tv/TMBH_Live" }),
|
||||
).resolves.toMatchObject({ live: true });
|
||||
expect(state.calls[0]?.url.searchParams.get("user_login")).toBe("tmbh_live");
|
||||
await expect(
|
||||
connector.resolveLiveSource?.(connection, { externalUrl: "https://player.example.com/embed?channel=TMBH_Live" }),
|
||||
).resolves.toMatchObject({ live: true });
|
||||
});
|
||||
|
||||
it("reports offline with a null playback URL and no caller error", async () => {
|
||||
const offline = harness();
|
||||
offline.state.live = false;
|
||||
const { connector } = offline;
|
||||
await expect(
|
||||
connector.resolveLiveSource?.(connection, { channelId: "tmbh_live" }),
|
||||
).resolves.toEqual({ playbackHlsUrl: null, live: false });
|
||||
});
|
||||
|
||||
it("constructs the Twitch HLS source for the requested live stream", async () => {
|
||||
const { connector } = harness();
|
||||
await expect(
|
||||
connector.getStreamPlaybackSources?.(connection, { remoteId: stream.id }),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
remoteId: stream.id,
|
||||
url: "https://usher.ttvnw.net/api/channel/hls/tmbh_live.m3u8",
|
||||
kind: "hls",
|
||||
protocol: "https",
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
connector.getStreamPlaybackSources?.(connection, { remoteId: "not-live" }),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("twitch connector: EventSub chat and webhooks", () => {
|
||||
it("creates EventSub subscriptions with condition, callback and secret", async () => {
|
||||
const { state, connector } = harness();
|
||||
const result = await connector.subscribeWebhooks?.(connection, {
|
||||
topics: ["stream.online", "channel.chat.message"],
|
||||
});
|
||||
expect(result?.subscriptionId).toBe("eventsub-1,eventsub-2");
|
||||
const calls = state.calls.filter((call) => call.url.pathname === "/helix/eventsub/subscriptions");
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(body(calls[0]?.init)).toEqual({
|
||||
type: "stream.online",
|
||||
version: "1",
|
||||
condition: { broadcaster_user_id: BROADCASTER_ID },
|
||||
transport: {
|
||||
method: "webhook",
|
||||
callback: "https://app.example.com/webhooks/twitch",
|
||||
secret: WEBHOOK_SECRET,
|
||||
},
|
||||
});
|
||||
expect(body(calls[1]?.init)).toMatchObject({
|
||||
type: "channel.chat.message",
|
||||
condition: { broadcaster_user_id: BROADCASTER_ID, user_id: BROADCASTER_ID },
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies HMAC-SHA256, normalizes, and buffers chat for both read surfaces", async () => {
|
||||
const { connector } = harness();
|
||||
await connector.getLiveStreams?.(connection, { limit: 10 });
|
||||
await connector.subscribeWebhooks?.(connection, { topics: ["stream.online"] });
|
||||
const signed = signedWebhook({
|
||||
subscription: { id: "sub-chat", type: "channel.chat.message", status: "enabled" },
|
||||
event: {
|
||||
broadcaster_user_id: BROADCASTER_ID,
|
||||
chatter_user_login: "viewer_one",
|
||||
message_id: "chat-1",
|
||||
message: { text: "hello chat" },
|
||||
},
|
||||
});
|
||||
const event = await connector.handleWebhook?.(signed);
|
||||
expect(event).toMatchObject({
|
||||
provider: "twitch",
|
||||
eventType: "channel.chat.message",
|
||||
connectionKey: connection.id,
|
||||
receivedAt: new Date("2026-10-07T10:01:00Z"),
|
||||
});
|
||||
const chat = await connector.getLiveChat?.(connection, { liveRemoteId: stream.id, limit: 10 });
|
||||
expect(chat?.items[0]).toMatchObject({
|
||||
id: "chat-1",
|
||||
authorHandle: "viewer_one",
|
||||
text: "hello chat",
|
||||
liveStreamId: stream.id,
|
||||
isModerated: false,
|
||||
});
|
||||
const comments = await connector.getComments?.(connection, { onRemoteId: stream.id, limit: 10 });
|
||||
expect(comments?.items[0]).toMatchObject({
|
||||
remoteId: "chat-1",
|
||||
onRemoteId: stream.id,
|
||||
authorHandle: "viewer_one",
|
||||
text: "hello chat",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an invalid EventSub signature and non-raw bodies", async () => {
|
||||
const { connector } = harness();
|
||||
await connector.subscribeWebhooks?.(connection, { topics: ["stream.online"] });
|
||||
const payload = {
|
||||
subscription: { id: "sub-1", type: "stream.online", status: "enabled" },
|
||||
event: { broadcaster_user_id: BROADCASTER_ID },
|
||||
};
|
||||
await expect(connector.handleWebhook?.(signedWebhook(payload, "wrong-secret"))).rejects.toThrow(
|
||||
/signature mismatch/,
|
||||
);
|
||||
await expect(
|
||||
connector.handleWebhook?.({ headers: signedWebhook(payload).headers, body: payload }),
|
||||
).rejects.toThrow(/raw body/);
|
||||
});
|
||||
|
||||
it("documents the IRC requirement and delegates when an adapter is supplied", async () => {
|
||||
const withoutIrc = harness().connector;
|
||||
await expect(
|
||||
withoutIrc.moderateLiveChat?.(connection, {
|
||||
liveRemoteId: stream.id,
|
||||
messageRemoteId: "chat-1",
|
||||
action: "delete",
|
||||
}),
|
||||
).rejects.toThrow(/IRC adapter/);
|
||||
|
||||
const calls: string[] = [];
|
||||
const withIrc = harness({
|
||||
ircModerate: async (_connection, request) => {
|
||||
calls.push(`${request.action}:${request.messageRemoteId}`);
|
||||
},
|
||||
}).connector;
|
||||
await withIrc.moderateLiveChat?.(connection, {
|
||||
liveRemoteId: stream.id,
|
||||
messageRemoteId: "chat-1",
|
||||
action: "delete",
|
||||
});
|
||||
expect(calls).toEqual(["delete:chat-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("twitch connector: analytics", () => {
|
||||
it("maps stream analytics totals and chronological series", async () => {
|
||||
const { state, connector } = harness();
|
||||
const result = await connector.getAnalytics?.(connection, {
|
||||
subjectRemoteId: stream.id,
|
||||
from: new Date("2026-10-06T00:00:00Z"),
|
||||
to: new Date("2026-10-08T00:00:00Z"),
|
||||
});
|
||||
expect(result).toEqual({
|
||||
values: { unique_viewers: 180, average_viewers: 55 },
|
||||
series: [
|
||||
{
|
||||
at: new Date("2026-10-06T10:00:00Z"),
|
||||
values: { unique_viewers: 80, average_viewers: 25 },
|
||||
},
|
||||
{
|
||||
at: new Date("2026-10-07T10:00:00Z"),
|
||||
values: { unique_viewers: 100, average_viewers: 30 },
|
||||
},
|
||||
],
|
||||
});
|
||||
const call = state.calls[0];
|
||||
expect(call?.url.pathname).toBe("/helix/analytics/streams");
|
||||
expect(call?.url.searchParams.get("broadcaster_id")).toBe(BROADCASTER_ID);
|
||||
expect(call?.url.searchParams.get("stream_id")).toBe(stream.id);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue