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);
|
||||
});
|
||||
});
|
||||
688
packages/connector-twitch/src/index.ts
Normal file
688
packages/connector-twitch/src/index.ts
Normal 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 }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue