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,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);
});
});

View file

@ -0,0 +1,688 @@
/**
* Twitch connector — Helix reads plus EventSub webhooks.
*
* Streams are created by pushing RTMP, not by a Helix create call, so this
* connector deliberately exposes neither createLiveStream nor
* updateLiveStream. Live chat has no history/polling endpoint: EventSub chat
* notifications received by handleWebhook are buffered for getLiveChat and
* getComments. Sending and moderation require IRC; only moderation is part of
* this connector's declared surface and needs an injected IRC adapter.
*
* Stream keys are credentials. This connector never returns a stream key or
* includes one in a LiveStream. An integration which retrieves one must put it
* into CredentialAccessor rather than expose the raw value to callers.
*/
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import type {
Comment,
ConnectorCapabilities,
ConnectorConnection,
CredentialRef,
LiveChatMessage,
LiveStream,
NormalizedIdentity,
LiveSourceResolution,
NormalizedWebhookEvent,
Page,
StreamPlaybackSource,
} from "@tactical-media-broadcast-hub/core";
import {
assertValidManifest,
AuthError,
CapabilityNotSupportedError,
ConnectorError,
InMemoryCredentials,
pageOf,
RateLimitedHttpClient,
RateLimitError,
type AuthorizationRequest,
type AuthorizationResult,
type ConnectorManifest,
type CredentialAccessor,
type PlatformConnector,
} from "@tactical-media-broadcast-hub/connector-sdk";
const PROVIDER = "twitch";
const API_BASE = "https://api.twitch.tv/helix";
const SIGNATURE_HEADER = "twitch-eventsub-message-signature";
const MESSAGE_ID_HEADER = "twitch-eventsub-message-id";
const TIMESTAMP_HEADER = "twitch-eventsub-message-timestamp";
const DEFAULT_WEBHOOK_TOPICS = ["stream.online", "stream.offline"];
const WEBHOOK_SECRET_KEY = "eventsub_webhook_secret";
const ACCESS_TOKEN_KEY = "access_token";
const CAPABILITIES: ConnectorCapabilities = {
publishText: false,
feed: false,
comments: true,
amplifyRepost: false,
amplifyQuote: false,
amplifyShare: false,
livestreams: true,
createLivestream: false,
manageLivestream: false,
livePlaybackSource: true,
liveChatRead: true,
liveChatWrite: false,
liveChatModeration: true,
analytics: true,
livestreamAnalytics: true,
webhooks: true,
};
export interface TwitchConnectorOptions {
fetchImpl?: typeof fetch;
requestsPerSecond?: number;
sleep?: (ms: number) => Promise<void>;
credentials?: CredentialAccessor;
/** Public HTTPS EventSub callback URL. */
webhookPublicUrl?: string;
/** Stable EventSub secret; overrides TWITCH_EVENTSUB_SECRET. */
webhookSecret?: string;
env?: Record<string, string | undefined>;
/** IRC bridge supplied by the host application for moderation operations. */
ircModerate?: (
connection: ConnectorConnection,
request: {
liveRemoteId: string;
messageRemoteId: string;
action: "delete" | "timeout" | "ban";
},
) => Promise<void>;
}
export interface TwitchAuthorizationRequest extends AuthorizationRequest {
fields?: Record<string, string>;
providerMetadata?: Record<string, unknown>;
}
interface HelixList<T> {
data: T[];
pagination?: { cursor?: string };
}
interface TwitchUser {
id: string;
login: string;
display_name?: string;
profile_image_url?: string;
broadcaster_type?: string;
}
interface TwitchStream {
id: string;
user_id: string;
user_login?: string;
user_name?: string;
type?: string;
title?: string;
viewer_count?: number;
started_at?: string;
game_name?: string;
thumbnail_url?: string;
}
interface EventSubSubscription {
id: string;
type?: string;
status?: string;
}
interface EventSubEnvelope {
subscription?: { id?: string; type?: string; status?: string };
event?: Record<string, unknown>;
challenge?: string;
}
interface StoredWebhookSecret {
secret: string;
connectionId: string;
}
function nonEmpty(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function headerValue(headers: Record<string, string>, name: string): string | undefined {
return Object.entries(headers).find(([key]) => key.toLowerCase() === name)?.[1];
}
function rawBodyBytes(body: unknown): Uint8Array {
if (typeof body === "string") return Buffer.from(body);
if (body instanceof Uint8Array) return body;
throw new ConnectorError(
`${PROVIDER}: handleWebhook needs the raw body (string or bytes) for signature verification`,
PROVIDER,
);
}
function thumbnailUrl(template: string | undefined): string | undefined {
if (template === undefined || template.length === 0) return undefined;
return template.replace("{width}", "1280").replace("{height}", "720");
}
/** Extract a twitch channel login from a URL path or ?channel= query. */
function loginFromUrl(raw: string | undefined): string | undefined {
if (raw === undefined || raw.length === 0) return undefined;
let url: URL;
try { url = new URL(raw); } catch { return undefined; }
const query = url.searchParams.get("channel");
if (query !== null && query.length > 0) return query.toLowerCase();
const segments = url.pathname.split("/").filter((segment) => segment.length > 0);
return segments.length > 0 ? segments[segments.length - 1]!.toLowerCase() : undefined;
}
function hlsUrl(login: string): string {
return `https://usher.ttvnw.net/api/channel/hls/${encodeURIComponent(login)}.m3u8`;
}
function mapLiveStream(connection: ConnectorConnection, stream: TwitchStream): LiveStream {
const preview = thumbnailUrl(stream.thumbnail_url);
const result: LiveStream = {
id: stream.id,
provider: PROVIDER,
connectionId: connection.id,
remoteId: stream.id,
title: stream.title ?? stream.user_name ?? stream.user_login ?? "Twitch livestream",
state: stream.type === "live" ? "live" : "waiting",
playbackUrls: preview === undefined ? [] : [preview],
};
if (stream.started_at !== undefined) result.startedAt = new Date(stream.started_at);
return result;
}
function eventText(event: Record<string, unknown>): string | undefined {
const message = event.message;
if (typeof message !== "object" || message === null) return undefined;
return nonEmpty((message as Record<string, unknown>).text);
}
function numericMetrics(record: Record<string, unknown>): Record<string, number> {
const result: Record<string, number> = {};
for (const [key, value] of Object.entries(record)) {
if (typeof value === "number" && Number.isFinite(value)) {
result[key] = value;
continue;
}
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
for (const [nestedKey, nestedValue] of Object.entries(value as Record<string, unknown>)) {
if (typeof nestedValue === "number" && Number.isFinite(nestedValue)) {
result[nestedKey] = nestedValue;
}
}
}
return result;
}
export function createTwitchConnector(options: TwitchConnectorOptions = {}): PlatformConnector {
const client = new RateLimitedHttpClient({
fetchImpl: options.fetchImpl,
requestsPerSecond: options.requestsPerSecond ?? 2,
sleep: options.sleep,
});
const credentials = options.credentials ?? new InMemoryCredentials();
const env = options.env ?? process.env;
const configuredWebhookSecret = options.webhookSecret ?? env.TWITCH_EVENTSUB_SECRET;
const webhookSecrets: StoredWebhookSecret[] =
configuredWebhookSecret === undefined || configuredWebhookSecret.length === 0
? []
: [{ secret: configuredWebhookSecret, connectionId: "" }];
const chatByStream = new Map<string, LiveChatMessage[]>();
const activeStreamByBroadcaster = new Map<string, string>();
const manifest: ConnectorManifest = {
id: PROVIDER,
displayName: "Twitch",
capabilities: CAPABILITIES,
connectionFields: [
{
name: "clientId",
label: "Client ID",
required: true,
secret: false,
},
{
name: "accessToken",
label: "OAuth access token",
required: true,
secret: true,
},
{
name: "broadcasterId",
label: "Broadcaster user ID",
required: true,
secret: false,
pattern: "^[0-9]+$",
},
],
oauth: {
kind: "oauth2-redirect",
authorizeUrlTemplate: "https://id.twitch.tv/oauth2/authorize",
scopes: [
"analytics:read:games",
"channel:manage:broadcast",
"user:read:chat",
"moderator:manage:chat_messages",
],
},
webhookRoute: "/webhooks/twitch",
docsUrl: "https://dev.twitch.tv/docs/api/",
};
function clientIdOf(connection: ConnectorConnection): string {
const clientId = nonEmpty(connection.fields.clientId);
if (clientId === undefined) {
throw new AuthError(`${PROVIDER}: connection ${connection.id} has no clientId`, PROVIDER, false);
}
return clientId;
}
function broadcasterIdOf(connection: ConnectorConnection): string {
const broadcasterId = nonEmpty(connection.fields.broadcasterId) ?? nonEmpty(connection.channelId);
if (broadcasterId === undefined) {
throw new ConnectorError(`${PROVIDER}: connection ${connection.id} has no broadcasterId`, PROVIDER);
}
return broadcasterId;
}
async function tokenOf(connection: ConnectorConnection): Promise<string> {
try {
return await credentials.getAccessToken(connection);
} catch {
const token = nonEmpty(connection.fields.accessToken);
if (token !== undefined) return token;
throw new AuthError(`${PROVIDER}: connection ${connection.id} has no access token`, PROVIDER, false);
}
}
async function helix<T>(
connection: ConnectorConnection,
method: "GET" | "POST",
path: string,
query: Record<string, string> = {},
body?: Record<string, unknown>,
): Promise<T> {
const url = new URL(`${API_BASE}${path}`);
for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value);
const headers: Record<string, string> = {
accept: "application/json",
"Client-ID": clientIdOf(connection),
Authorization: `Bearer ${await tokenOf(connection)}`,
};
const init: RequestInit = { method, headers };
if (body !== undefined) {
headers["content-type"] = "application/json";
init.body = JSON.stringify(body);
}
const response = await client.request(PROVIDER, url.toString(), init);
if (!response.ok) {
let detail = "";
try {
const error = (await response.json()) as { message?: unknown };
if (typeof error.message === "string") detail = `: ${error.message}`;
} catch {
detail = "";
}
const message = `${PROVIDER}: HTTP ${response.status} ${method} ${url.pathname}${detail}`;
if (response.status === 401 || response.status === 403) {
throw new AuthError(message, PROVIDER, false);
}
if (response.status === 429) throw new RateLimitError(PROVIDER);
throw new ConnectorError(message, PROVIDER);
}
const text = await response.text();
return (text.length === 0 ? {} : JSON.parse(text)) as T;
}
async function identity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
const response = await helix<HelixList<TwitchUser>>(connection, "GET", "/users");
const user = response.data[0];
if (user === undefined) {
throw new AuthError(`${PROVIDER}: /users returned no identity for this token`, PROVIDER, false);
}
const result: NormalizedIdentity = {
provider: PROVIDER,
remoteId: user.id,
handle: user.login,
};
if (user.display_name !== undefined) result.displayName = user.display_name;
if (user.profile_image_url !== undefined) result.avatarUrl = user.profile_image_url;
return result;
}
async function streams(
connection: ConnectorConnection,
cursor: string | undefined,
limit: number,
): Promise<HelixList<TwitchStream>> {
const query: Record<string, string> = {
user_id: broadcasterIdOf(connection),
first: String(Math.max(1, Math.min(100, limit))),
};
if (cursor !== undefined) query.after = cursor;
return helix<HelixList<TwitchStream>>(connection, "GET", "/streams", query);
}
function registerWebhookSecret(connection: ConnectorConnection, secret: string): void {
const configured = webhookSecrets.find(
(item) => item.connectionId.length === 0 && item.secret === secret,
);
if (configured !== undefined) {
configured.connectionId = connection.id;
return;
}
const existing = webhookSecrets.find((item) => item.connectionId === connection.id);
if (existing === undefined) webhookSecrets.push({ secret, connectionId: connection.id });
else existing.secret = secret;
}
async function webhookSecretFor(connection: ConnectorConnection): Promise<string> {
const configured = options.webhookSecret ?? env.TWITCH_EVENTSUB_SECRET;
if (configured !== undefined && configured.length > 0) {
registerWebhookSecret(connection, configured);
return configured;
}
try {
const saved = await credentials.getSecret(connection, WEBHOOK_SECRET_KEY);
registerWebhookSecret(connection, saved);
return saved;
} catch {
const generated = randomBytes(32).toString("hex");
await credentials.put(connection, WEBHOOK_SECRET_KEY, generated);
registerWebhookSecret(connection, generated);
return generated;
}
}
function verifyWebhook(
headers: Record<string, string>,
bytes: Uint8Array,
): StoredWebhookSecret {
const signature = headerValue(headers, SIGNATURE_HEADER);
const messageId = headerValue(headers, MESSAGE_ID_HEADER);
const timestamp = headerValue(headers, TIMESTAMP_HEADER);
if (signature === undefined || messageId === undefined || timestamp === undefined) {
throw new AuthError(`${PROVIDER}: webhook is missing Twitch EventSub signature headers`, PROVIDER, false);
}
const supplied = Buffer.from(signature);
for (const candidate of webhookSecrets) {
const expected = Buffer.from(
`sha256=${createHmac("sha256", candidate.secret)
.update(messageId)
.update(timestamp)
.update(bytes)
.digest("hex")}`,
);
if (supplied.length === expected.length && timingSafeEqual(supplied, expected)) return candidate;
}
throw new AuthError(`${PROVIDER}: webhook signature mismatch`, PROVIDER, false);
}
function bufferChatEvent(event: Record<string, unknown>, receivedAt: Date): void {
const messageId = nonEmpty(event.message_id);
const text = eventText(event);
const broadcasterId = nonEmpty(event.broadcaster_user_id);
if (messageId === undefined || text === undefined || broadcasterId === undefined) return;
const streamId = activeStreamByBroadcaster.get(broadcasterId) ?? broadcasterId;
const message: LiveChatMessage = {
id: messageId,
provider: PROVIDER,
liveStreamId: streamId,
authorHandle:
nonEmpty(event.chatter_user_login) ?? nonEmpty(event.chatter_user_name) ?? "unknown",
text,
sentAt: receivedAt,
isModerated: false,
};
const messages = chatByStream.get(streamId) ?? [];
messages.push(message);
chatByStream.set(streamId, messages);
}
function chatPage(streamId: string, cursor: string | undefined, limit: number): Page<LiveChatMessage> {
const all = chatByStream.get(streamId) ?? [];
const parsed = cursor === undefined ? 0 : Number(cursor);
const start = Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
const items = all.slice(start, start + Math.max(0, limit));
const next = start + items.length;
return next < all.length ? pageOf(items, String(next)) : pageOf(items);
}
return {
manifest: () => {
assertValidManifest(manifest);
return manifest;
},
async authorize(input: TwitchAuthorizationRequest): Promise<AuthorizationResult> {
const fields = input.fields ?? {};
const clientId = nonEmpty(fields.clientId);
const accessToken = nonEmpty(fields.accessToken);
const broadcasterId = nonEmpty(fields.broadcasterId);
if (clientId === undefined || accessToken === undefined || broadcasterId === undefined) {
throw new ConnectorError(
`${PROVIDER}: complete OAuth first, then provide fields.clientId, fields.accessToken and fields.broadcasterId`,
PROVIDER,
);
}
const metadata = input.providerMetadata ?? {};
const connectionId = nonEmpty(metadata.connectionId) ?? `${PROVIDER}:${input.workspaceId}:${broadcasterId}`;
const credentialId: CredentialRef = { kind: "credential-ref", id: connectionId };
const connection: ConnectorConnection = {
id: connectionId,
workspaceId: input.workspaceId,
provider: PROVIDER,
channelId: broadcasterId,
credentialId,
fields: { clientId, broadcasterId },
createdAt: new Date(),
};
await credentials.put(connection, ACCESS_TOKEN_KEY, accessToken);
return { connection, identity: await identity(connection) };
},
async getIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
return identity(connection);
},
async getLiveStreams(
connection: ConnectorConnection,
request: { cursor?: string; limit: number },
): Promise<Page<LiveStream>> {
const response = await streams(connection, request.cursor, request.limit);
const items = response.data.map((stream) => {
activeStreamByBroadcaster.set(stream.user_id, stream.id);
return mapLiveStream(connection, stream);
});
return pageOf(items, response.pagination?.cursor);
},
async resolveLiveSource(
connection: ConnectorConnection,
request: { channelId?: string; externalUrl?: string },
): Promise<LiveSourceResolution> {
// A login may arrive as a channel id field or inside an external URL;
// Helix answers both through user_login.
const login = nonEmpty(request.channelId) ?? loginFromUrl(request.externalUrl);
if (login === undefined) {
throw new ConnectorError(`${PROVIDER}: resolveLiveSource needs a channelId or externalUrl`, PROVIDER);
}
const response = await helix<HelixList<TwitchStream>>(connection, "GET", "/streams", {
user_login: login,
first: "1",
});
const stream = response.data[0];
if (stream === undefined || stream.type !== "live" || stream.user_login === undefined) {
return { playbackHlsUrl: null, live: false };
}
return { playbackHlsUrl: hlsUrl(stream.user_login), live: true };
},
async getStreamPlaybackSources(
connection: ConnectorConnection,
request: { remoteId: string },
): Promise<StreamPlaybackSource[]> {
const response = await streams(connection, undefined, 100);
const stream = response.data.find((candidate) => candidate.id === request.remoteId);
if (stream === undefined || stream.type !== "live") return [];
const login = nonEmpty(stream.user_login);
if (login === undefined) {
throw new ConnectorError(`${PROVIDER}: stream ${stream.id} has no user_login for HLS playback`, PROVIDER);
}
return [
{
remoteId: stream.id,
url: hlsUrl(login),
kind: "hls",
protocol: "https",
},
];
},
async getLiveChat(
_connection: ConnectorConnection,
request: { liveRemoteId: string; cursor?: string; limit: number },
): Promise<Page<LiveChatMessage>> {
return chatPage(request.liveRemoteId, request.cursor, request.limit);
},
async getComments(
_connection: ConnectorConnection,
request: { onRemoteId: string; cursor?: string; limit: number },
): Promise<Page<Comment>> {
const page = chatPage(request.onRemoteId, request.cursor, request.limit);
return {
items: page.items.map((message) => ({
id: message.id,
provider: PROVIDER,
remoteId: message.id,
onRemoteId: request.onRemoteId,
authorHandle: message.authorHandle,
text: message.text,
createdAt: message.sentAt,
})),
...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
};
},
async moderateLiveChat(
connection: ConnectorConnection,
request: {
liveRemoteId: string;
messageRemoteId: string;
action: "delete" | "timeout" | "ban";
},
): Promise<void> {
if (options.ircModerate === undefined) {
throw new CapabilityNotSupportedError(
PROVIDER,
"live chat moderation without an IRC adapter (Twitch provides no REST operation for this connector)",
);
}
await options.ircModerate(connection, request);
const messages = chatByStream.get(request.liveRemoteId) ?? [];
const message = messages.find((candidate) => candidate.id === request.messageRemoteId);
if (message !== undefined) message.isModerated = true;
},
async getAnalytics(
connection: ConnectorConnection,
request: { subjectRemoteId?: string; from: Date; to: Date },
): Promise<{ values: Record<string, number>; series: { at: Date; values: Record<string, number> }[] }> {
const response = await helix<HelixList<Record<string, unknown>>>(
connection,
"GET",
"/analytics/streams",
{
broadcaster_id: broadcasterIdOf(connection),
started_at: request.from.toISOString(),
ended_at: request.to.toISOString(),
...(request.subjectRemoteId === undefined ? {} : { stream_id: request.subjectRemoteId }),
},
);
const values: Record<string, number> = {};
const series: { at: Date; values: Record<string, number> }[] = [];
for (const record of response.data) {
const metrics = numericMetrics(record);
for (const [name, value] of Object.entries(metrics)) values[name] = (values[name] ?? 0) + value;
const at = nonEmpty(record.started_at) ?? nonEmpty(record.timestamp) ?? nonEmpty(record.date);
if (at !== undefined) series.push({ at: new Date(at), values: metrics });
}
series.sort((left, right) => left.at.getTime() - right.at.getTime());
return { values, series };
},
async subscribeWebhooks(
connection: ConnectorConnection,
request: { topics: string[] },
): Promise<{ subscriptionId: string }> {
const callback = options.webhookPublicUrl ?? env.TWITCH_EVENTSUB_CALLBACK_URL;
if (callback === undefined || callback.length === 0) {
throw new ConnectorError(
`${PROVIDER}: pass webhookPublicUrl or set TWITCH_EVENTSUB_CALLBACK_URL before subscribing`,
PROVIDER,
);
}
const secret = await webhookSecretFor(connection);
const topics = request.topics.length > 0 ? request.topics : DEFAULT_WEBHOOK_TOPICS;
const subscriptions: string[] = [];
for (const topic of topics) {
const condition: Record<string, string> = {
broadcaster_user_id: broadcasterIdOf(connection),
};
if (topic === "channel.chat.message") condition.user_id = broadcasterIdOf(connection);
const response = await helix<HelixList<EventSubSubscription>>(
connection,
"POST",
"/eventsub/subscriptions",
{},
{
type: topic,
version: "1",
condition,
transport: { method: "webhook", callback, secret },
},
);
const id = response.data[0]?.id;
if (id === undefined) {
throw new ConnectorError(`${PROVIDER}: EventSub returned no subscription id for ${topic}`, PROVIDER);
}
subscriptions.push(id);
}
return { subscriptionId: subscriptions.join(",") };
},
async handleWebhook(request: {
headers: Record<string, string>;
body: unknown;
}): Promise<NormalizedWebhookEvent> {
const bytes = rawBodyBytes(request.body);
const matched = verifyWebhook(request.headers, bytes);
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(bytes).toString("utf8"));
} catch (cause) {
throw new ConnectorError(`${PROVIDER}: webhook body is not JSON`, PROVIDER, cause);
}
if (typeof parsed !== "object" || parsed === null) {
throw new ConnectorError(`${PROVIDER}: malformed EventSub payload`, PROVIDER);
}
const envelope = parsed as EventSubEnvelope;
const eventType = nonEmpty(envelope.subscription?.type);
if (eventType === undefined) {
throw new ConnectorError(`${PROVIDER}: EventSub payload has no subscription type`, PROVIDER);
}
const receivedAt = new Date(headerValue(request.headers, TIMESTAMP_HEADER) ?? Date.now());
if (eventType === "channel.chat.message" && envelope.event !== undefined) {
bufferChatEvent(envelope.event, receivedAt);
}
return {
provider: PROVIDER,
eventType,
payload: parsed as Record<string, unknown>,
receivedAt,
...(matched.connectionId.length === 0 ? {} : { connectionKey: matched.connectionId }),
};
},
};
}