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/.
415 lines
14 KiB
TypeScript
415 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|