flybrain/services/bridge/tests/eventsub.test.ts
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

510 lines
20 KiB
TypeScript

/**
* `startEventSub` wiring (`src/eventsub.ts`), through the `createListener` seam: no twurple, no
* Twitch, no sockets.
*
* Two properties are asserted here, both of them direct consequences of the 2026-09-16 the release container
* outage (`src/subscription-health.ts`):
*
* 1. TRANSPORT COUNT. One Twitch account holding both roles gets ONE listener, because
* `EventSubWsListener` opens a socket per auth user id and two sockets from one account spent
* two of Twitch's three per-user websocket transports. Two accounts still get two listeners.
* 2. SELF-HEALING. A `channel.chat.message` create failure with no confirmation inside the grace
* period reaches `onUnhealthy`; a confirmation inside it does not.
* 3. QUIET MODE (`FEATURE_QUIET`). Follows and raids stay subscribed and counted but say nothing,
* while commands still answer and viewer chat still reaches the on-screen panel.
*/
import assert from 'node:assert/strict';
import type { ApiClient } from '@twurple/api';
import type {
EventSubChannelChatMessageEvent,
EventSubChannelFollowEvent,
EventSubChannelRaidEvent,
} from '@twurple/eventsub-base';
import test from 'node:test';
import { createSend } from '../src/chat';
import { startEventSub, type EventSubListenerLike, type EventSubSubscriptionLike } from '../src/eventsub';
import { ExplainerPoster } from '../src/explainer';
import { HealthState, Metrics } from '../src/health';
import { OnscreenChat } from '../src/onscreen-chat';
import { FakeClock, RateLimiter } from '../src/ratelimit';
import {
ChatSubscriptionHealth,
type ChatSubscriptionLostReport,
type Timers,
} from '../src/subscription-health';
import { FakeChatSender, FakeSimClient } from './helpers';
const BROADCASTER_ID = '<broadcaster-id>';
/** The first live channel runs both roles on this one account (docs/design/stage-bridge.md B1). */
const SAME_ACCOUNT_BOT_ID = BROADCASTER_ID;
/** The planned separate bot account. */
const SEPARATE_BOT_ID = '999000111';
class ManualTimers implements Timers {
private next = 1;
private readonly pending = new Map<number, () => void>();
set(callback: () => void): unknown {
const handle = this.next++;
this.pending.set(handle, callback);
return handle;
}
clear(handle: unknown): void {
this.pending.delete(handle as number);
}
fire(): void {
for (const [handle, callback] of [...this.pending.entries()]) {
this.pending.delete(handle);
callback();
}
}
}
/**
* A recording stand-in for `EventSubWsListener`, implementing only `EventSubListenerLike`. Its
* `emit*` methods are how a test plays Twitch: "your socket dropped", "the create failed", "the
* create succeeded".
*/
class FakeListener implements EventSubListenerLike {
readonly apiClient: ApiClient;
starts = 0;
stops = 0;
readonly created: string[] = [];
private chatHandlers: ((event: EventSubChannelChatMessageEvent) => void)[] = [];
private followHandlers: ((event: EventSubChannelFollowEvent) => void)[] = [];
private raidHandlers: ((event: EventSubChannelRaidEvent) => void)[] = [];
private connectHandlers: ((userId: string) => void)[] = [];
private disconnectHandlers: ((userId: string, error?: Error) => void)[] = [];
private revokeHandlers: ((subscription: EventSubSubscriptionLike, status: string) => void)[] = [];
private successHandlers: ((subscription: EventSubSubscriptionLike) => void)[] = [];
private failureHandlers: ((subscription: EventSubSubscriptionLike, error: Error) => void)[] = [];
constructor(apiClient: ApiClient) {
this.apiClient = apiClient;
}
onUserSocketConnect(handler: (userId: string) => void): unknown {
this.connectHandlers.push(handler);
return null;
}
onUserSocketDisconnect(handler: (userId: string, error?: Error) => void): unknown {
this.disconnectHandlers.push(handler);
return null;
}
onRevoke(handler: (subscription: EventSubSubscriptionLike, status: string) => void): unknown {
this.revokeHandlers.push(handler);
return null;
}
onSubscriptionCreateSuccess(handler: (subscription: EventSubSubscriptionLike) => void): unknown {
this.successHandlers.push(handler);
return null;
}
onSubscriptionCreateFailure(handler: (subscription: EventSubSubscriptionLike, error: Error) => void): unknown {
this.failureHandlers.push(handler);
return null;
}
onChannelChatMessage(
broadcasterId: string,
userId: string,
handler: (event: EventSubChannelChatMessageEvent) => void,
): EventSubSubscriptionLike {
this.chatHandlers.push(handler);
return this.record(`channel.chat.message.${broadcasterId}.${userId}`);
}
onChannelFollow(
broadcasterId: string,
moderatorId: string,
handler: (event: EventSubChannelFollowEvent) => void,
): EventSubSubscriptionLike {
this.followHandlers.push(handler);
return this.record(`channel.follow.${broadcasterId}.${moderatorId}`);
}
onChannelRaidTo(
broadcasterId: string,
handler: (event: EventSubChannelRaidEvent) => void,
): EventSubSubscriptionLike {
this.raidHandlers.push(handler);
return this.record(`channel.raid.to.${broadcasterId}`);
}
onChannelRedemptionAddForReward(broadcasterId: string, rewardId: string): EventSubSubscriptionLike {
return this.record(`channel.channel_points_custom_reward_redemption.add.${broadcasterId}.${rewardId}`);
}
start(): void {
this.starts += 1;
}
stop(): void {
this.stops += 1;
}
/** One chat message from a viewer. Only the four fields the handler reads are populated. */
emitChat(messageText: string, chatter = 'a_viewer'): void {
const event = {
chatterId: '424242',
chatterName: chatter,
chatterDisplayName: chatter,
messageText,
} as unknown as EventSubChannelChatMessageEvent;
for (const handler of this.chatHandlers) handler(event);
}
emitFollow(userDisplayName: string): void {
for (const handler of this.followHandlers) {
handler({ userDisplayName } as unknown as EventSubChannelFollowEvent);
}
}
emitRaid(raidingBroadcasterDisplayName: string, viewers: number): void {
for (const handler of this.raidHandlers) {
handler({ raidingBroadcasterDisplayName, viewers } as unknown as EventSubChannelRaidEvent);
}
}
emitConnect(userId: string): void {
for (const handler of this.connectHandlers) handler(userId);
}
emitDisconnect(userId: string, error?: Error): void {
for (const handler of this.disconnectHandlers) handler(userId, error);
}
emitRevoke(id: string, status: string): void {
for (const handler of this.revokeHandlers) handler({ id }, status);
}
emitCreateSuccess(id: string): void {
for (const handler of this.successHandlers) handler({ id });
}
emitCreateFailure(id: string, error: Error): void {
for (const handler of this.failureHandlers) handler({ id }, error);
}
private record(id: string): EventSubSubscriptionLike {
this.created.push(id);
return { id };
}
}
/** Distinct sentinels: the assertions below care WHICH client a listener was handed. */
const BOT_CLIENT = { role: 'bot' } as unknown as ApiClient;
const BROADCASTER_CLIENT = { role: 'broadcaster' } as unknown as ApiClient;
const SHARED_CLIENT = { role: 'shared' } as unknown as ApiClient;
interface Harness {
listeners: FakeListener[];
timers: ManualTimers;
reports: ChatSubscriptionLostReport[];
health: HealthState;
metrics: Metrics;
/** Every chat line this bridge would have sent to Twitch, rendered. */
sender: FakeChatSender;
/** Records `POST /chat` (the on-screen panel) and `POST /stimulate` calls. */
sim: FakeSimClient;
result: ReturnType<typeof startEventSub>;
}
/** Let the `void handle*()` promises the handlers kick off settle before asserting. */
async function flush(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
function start(options: {
botUserId: string;
sharedEventSubApiClient?: ApiClient | null;
quiet?: boolean;
/** 0 lifts the 5 s global reply bucket, for a test that fires several commands at once. */
globalReplyMs?: number;
}): Harness {
const created: FakeListener[] = [];
const timers = new ManualTimers();
const reports: ChatSubscriptionLostReport[] = [];
const health = new HealthState();
const metrics = new Metrics();
const sim = new FakeSimClient();
const config = { gameTitle: 'Pokemon Red', featureQuiet: options.quiet ?? false };
const sender = new FakeChatSender();
const send = createSend(sender);
const chatHealth = new ChatSubscriptionHealth({
graceMs: 60_000,
timers,
clock: new FakeClock(),
onUnhealthy: (report) => reports.push(report),
log: () => undefined,
});
const result = startEventSub({
botApiClient: BOT_CLIENT,
broadcasterApiClient: BROADCASTER_CLIENT,
sharedEventSubApiClient: options.sharedEventSubApiClient,
botUserId: options.botUserId,
broadcasterUserId: BROADCASTER_ID,
send,
sim,
rateLimiter: new RateLimiter({
perUserPerCommandMs: 60_000,
globalReplyMs: options.globalReplyMs ?? 5_000,
chatMessagesPer30s: 15,
perCommandCooldownMs: {},
}),
config,
health,
metrics,
explainer: new ExplainerPoster({ send, config, intervalMs: 1, silenceMs: 1 }),
onscreen: new OnscreenChat({ sim, config: { featureOnscreenChat: true, botUser: '<twitch-channel>' }, metrics }),
redemptions: null,
chatHealth,
createListener: ({ apiClient }) => {
const listener = new FakeListener(apiClient);
created.push(listener);
return listener;
},
});
return { listeners: created, timers, reports, health, metrics, sender, sim, result };
}
void test('one Twitch account for both roles collapses to ONE listener, on the shared client', () => {
const h = start({ botUserId: SAME_ACCOUNT_BOT_ID, sharedEventSubApiClient: SHARED_CLIENT });
assert.equal(h.listeners.length, 1, 'one websocket transport, not two');
assert.equal(h.result.listenerCount, 1);
assert.equal(h.result.collapsed, true);
assert.equal(h.result.bot, h.result.broadcaster, 'both roles point at the same listener');
assert.equal(h.listeners[0]!.apiClient, SHARED_CLIENT);
assert.equal(h.listeners[0]!.starts, 1, 'started exactly once');
assert.equal(h.health.eventSubListenerCount, 0, 'index.ts publishes the count, not startEventSub');
// Chat, follow and raid all live on that one listener.
assert.deepEqual(h.listeners[0]!.created, [
`channel.chat.message.${BROADCASTER_ID}.${SAME_ACCOUNT_BOT_ID}`,
`channel.follow.${BROADCASTER_ID}.${BROADCASTER_ID}`,
`channel.raid.to.${BROADCASTER_ID}`,
]);
assert.equal(h.result.chatSubscriptionId, `channel.chat.message.${BROADCASTER_ID}.${SAME_ACCOUNT_BOT_ID}`);
});
void test('separate bot and broadcaster accounts keep TWO listeners, one per role token', () => {
const h = start({ botUserId: SEPARATE_BOT_ID, sharedEventSubApiClient: SHARED_CLIENT });
assert.equal(h.listeners.length, 2);
assert.equal(h.result.listenerCount, 2);
assert.equal(h.result.collapsed, false);
assert.notEqual(h.result.bot, h.result.broadcaster);
assert.equal(h.listeners[0]!.apiClient, BOT_CLIENT, 'chat on the bot token');
assert.equal(h.listeners[1]!.apiClient, BROADCASTER_CLIENT, 'follows and raids on the broadcaster token');
assert.deepEqual(h.listeners[0]!.created, [`channel.chat.message.${BROADCASTER_ID}.${SEPARATE_BOT_ID}`]);
assert.deepEqual(h.listeners[1]!.created, [
`channel.follow.${BROADCASTER_ID}.${BROADCASTER_ID}`,
`channel.raid.to.${BROADCASTER_ID}`,
]);
assert.equal(h.listeners[0]!.starts, 1);
assert.equal(h.listeners[1]!.starts, 1);
});
void test('one account with no shared client falls back to two listeners rather than dropping a role', () => {
const h = start({ botUserId: SAME_ACCOUNT_BOT_ID, sharedEventSubApiClient: null });
assert.equal(h.listeners.length, 2);
assert.equal(h.result.listenerCount, 2);
});
void test('the watchdog is armed from startup and disarmed by the first confirmation', () => {
const h = start({ botUserId: SAME_ACCOUNT_BOT_ID, sharedEventSubApiClient: SHARED_CLIENT });
assert.equal(h.health.chatSubscriptionHealthy, false, 'nothing is confirmed until Twitch says so');
h.listeners[0]!.emitConnect(BROADCASTER_ID);
assert.equal(h.health.eventSubConnected, true);
// A connected socket is NOT a working chat subscription — that distinction is the whole outage.
assert.equal(h.health.chatSubscriptionHealthy, false);
h.listeners[0]!.emitCreateSuccess(h.result.chatSubscriptionId);
assert.equal(h.health.chatSubscriptionHealthy, true);
assert.equal(h.metrics.get('flybridge_eventsub_chat_subscription_confirmed_total'), 1);
h.timers.fire();
assert.deepEqual(h.reports, [], 'a confirmed subscription must never exit the process');
});
void test('a disconnect then a 429 transport-limit create failure, with no confirmation, asks for the exit', () => {
const h = start({ botUserId: SAME_ACCOUNT_BOT_ID, sharedEventSubApiClient: SHARED_CLIENT });
const listener = h.listeners[0]!;
listener.emitConnect(BROADCASTER_ID);
listener.emitCreateSuccess(h.result.chatSubscriptionId);
assert.equal(h.health.chatSubscriptionHealthy, true);
// 11:45:34 UTC: code 1006 on both sockets.
listener.emitDisconnect(BROADCASTER_ID, new Error('Connection closed abnormally (1006)'));
assert.equal(h.health.eventSubConnected, false);
assert.equal(h.metrics.get('flybridge_eventsub_reconnects_total'), 1);
// 11:45:35 and 11:45:45 UTC: twurple re-creates, Twitch refuses.
const limit = Object.assign(new Error('number of websocket transports limit exceeded'), { statusCode: 429 });
listener.emitCreateFailure(h.result.chatSubscriptionId, limit);
listener.emitCreateFailure(h.result.chatSubscriptionId, limit);
assert.equal(h.metrics.get('flybridge_eventsub_transport_limit_total'), 2);
assert.equal(h.metrics.get('flybridge_eventsub_subscription_create_failures_total'), 2);
assert.equal(h.reports.length, 0, 'still inside the grace period');
assert.equal(h.health.chatSubscriptionHealthy, false, '/health says chat is dead while the grace runs');
// 11:46:34 UTC: the grace period is up and nothing has confirmed.
h.timers.fire();
assert.equal(h.reports.length, 1);
assert.equal(h.reports[0]!.transportLimitHit, true);
assert.equal(h.health.chatSubscriptionHealthy, false);
});
void test('a confirmation after the reconnect, inside the grace period, does not exit', () => {
const h = start({ botUserId: SAME_ACCOUNT_BOT_ID, sharedEventSubApiClient: SHARED_CLIENT });
const listener = h.listeners[0]!;
listener.emitConnect(BROADCASTER_ID);
listener.emitCreateSuccess(h.result.chatSubscriptionId);
listener.emitDisconnect(BROADCASTER_ID, new Error('Connection closed abnormally (1006)'));
listener.emitCreateFailure(h.result.chatSubscriptionId, new Error('number of websocket transports limit exceeded'));
// The healthy path: the next attempt lands.
listener.emitConnect(BROADCASTER_ID);
listener.emitCreateSuccess(h.result.chatSubscriptionId);
h.timers.fire();
assert.deepEqual(h.reports, []);
assert.equal(h.health.chatSubscriptionHealthy, true);
assert.equal(h.health.eventSubConnected, true);
});
void test('a create failure on a subscription that is not chat does not arm the watchdog', () => {
const h = start({ botUserId: SAME_ACCOUNT_BOT_ID, sharedEventSubApiClient: SHARED_CLIENT });
const listener = h.listeners[0]!;
listener.emitCreateSuccess(h.result.chatSubscriptionId);
listener.emitCreateFailure(`channel.follow.${BROADCASTER_ID}.${BROADCASTER_ID}`, new Error('missing scope'));
h.timers.fire();
assert.deepEqual(h.reports, [], 'losing follows must not restart the chat bridge');
assert.equal(h.metrics.get('flybridge_eventsub_subscription_create_failures_total'), 1);
assert.equal(h.metrics.get('flybridge_eventsub_chat_subscription_lost_total'), 0);
assert.equal(h.health.chatSubscriptionHealthy, true);
});
void test('Twitch revoking the chat subscription arms the watchdog and shows up in /health', () => {
const h = start({ botUserId: SAME_ACCOUNT_BOT_ID, sharedEventSubApiClient: SHARED_CLIENT });
const listener = h.listeners[0]!;
listener.emitCreateSuccess(h.result.chatSubscriptionId);
listener.emitRevoke(h.result.chatSubscriptionId, 'authorization_revoked');
assert.equal(
h.health.subscriptions.find((s) => s.type === h.result.chatSubscriptionId)?.status,
'authorization_revoked',
);
h.timers.fire();
assert.equal(h.reports.length, 1);
assert.match(h.reports[0]!.reasons[0]!, /revoked by Twitch/);
});
void test('with two listeners, only the chat one arms the watchdog on a socket drop', () => {
const h = start({ botUserId: SEPARATE_BOT_ID, sharedEventSubApiClient: null });
const [chatListener, broadcasterListener] = [h.listeners[0]!, h.listeners[1]!];
chatListener.emitConnect(SEPARATE_BOT_ID);
broadcasterListener.emitConnect(BROADCASTER_ID);
chatListener.emitCreateSuccess(h.result.chatSubscriptionId);
broadcasterListener.emitDisconnect(BROADCASTER_ID, new Error('1006'));
h.timers.fire();
assert.deepEqual(h.reports, [], 'the redemptions socket dropping does not kill chat');
assert.equal(h.health.eventSubConnected, false, 'but /health still reports the bridge as degraded');
});
void test('stop() stops every distinct listener exactly once, and the collapsed one only once', () => {
const collapsed = start({ botUserId: SAME_ACCOUNT_BOT_ID, sharedEventSubApiClient: SHARED_CLIENT });
collapsed.result.stop();
assert.equal(collapsed.listeners[0]!.stops, 1);
const split = start({ botUserId: SEPARATE_BOT_ID, sharedEventSubApiClient: null });
split.result.stop();
assert.equal(split.listeners[0]!.stops, 1);
assert.equal(split.listeners[1]!.stops, 1);
// Shutdown must not race a self-healing exit.
split.timers.fire();
assert.deepEqual(split.reports, []);
});
// -- Quiet mode (FEATURE_QUIET) ----------------------------------------------------------------
void test('quiet mode keeps the follow and raid subscriptions, counts them, and says nothing', async () => {
const h = start({ botUserId: SAME_ACCOUNT_BOT_ID, sharedEventSubApiClient: SHARED_CLIENT, quiet: true });
const listener = h.listeners[0]!;
// The subscriptions are still there — /health and the metrics must not change shape just
// because the bridge stopped greeting.
assert.deepEqual(listener.created, [
`channel.chat.message.${BROADCASTER_ID}.${SAME_ACCOUNT_BOT_ID}`,
`channel.follow.${BROADCASTER_ID}.${BROADCASTER_ID}`,
`channel.raid.to.${BROADCASTER_ID}`,
]);
listener.emitFollow('fly_fan_42');
listener.emitRaid('another_streamer', 12);
await flush();
assert.deepEqual(h.sender.sent, [], 'no follow or raid thanks in quiet mode');
assert.equal(h.metrics.get('flybridge_follows_total'), 1);
assert.equal(h.metrics.get('flybridge_raids_total'), 1);
});
void test('follows and raids are thanked, and counted, when quiet mode is off', async () => {
const h = start({ botUserId: SAME_ACCOUNT_BOT_ID, sharedEventSubApiClient: SHARED_CLIENT });
h.listeners[0]!.emitFollow('fly_fan_42');
h.listeners[0]!.emitRaid('another_streamer', 12);
await flush();
assert.equal(h.sender.sent.length, 2);
assert.match(h.sender.sent[0]!, /Thanks for the follow, fly_fan_42/);
assert.match(h.sender.sent[1]!, /Thanks for the raid, another_streamer/);
assert.equal(h.metrics.get('flybridge_follows_total'), 1);
assert.equal(h.metrics.get('flybridge_raids_total'), 1);
});
void test('quiet mode still answers every command and still forwards chat to the panel', async () => {
const h = start({
botUserId: SAME_ACCOUNT_BOT_ID,
sharedEventSubApiClient: SHARED_CLIENT,
quiet: true,
globalReplyMs: 0,
});
const listener = h.listeners[0]!;
for (const [index, command] of ['!fly', '!brain', '!how', '!stuck', '!sugar'].entries()) {
// A fresh chatter id per command would be simpler, but the per-user-per-command bucket is
// keyed by (user, command), so one viewer can use all five.
listener.emitChat(command, `viewer${String(index)}`);
}
await flush();
assert.equal(h.sender.sent.length, 5, 'spoken to, so it speaks: one reply per command');
assert.match(h.sender.sent[0]!, /simulated fly brain/);
assert.match(h.sender.sent[2]!, /!fly what this is/);
assert.match(h.sender.sent[3]!, /Current milestone/);
assert.match(h.sender.sent[4]!, /Sugar from/);
assert.equal(h.sim.stimulateCalls.length, 1, '!sugar still reaches the sim');
// Commands are not chatter and never go on the panel; a plain viewer line does.
listener.emitChat('the fly just walked into a wall', 'chatty_viewer');
await flush();
assert.equal(h.sim.chatCalls.length, 1);
assert.equal(h.sim.chatCalls[0]!.text, 'the fly just walked into a wall');
});