/** * The chat-to-screen path (`src/onscreen-chat.ts`), plus the static lint that keeps it the ONLY * path: no raw chat may reach a sim payload by any other route. * * Two kinds of test here: * - behavioural, against `FakeSimClient` and (for the real HTTP status codes) `tests/fake-sim.ts`; * - a static-analysis lint over `src/`, in the same spirit as the `send(...)` template lint in * `tests/templates.test.ts`. */ import assert from 'node:assert/strict'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { HttpSimClient } from '../src/sim'; import { FakeClock } from '../src/ratelimit'; import { sanitizeChatText } from '@flybrain/feed'; import { ECHO_WINDOW_MS, KNOWN_BOT_LOGINS, OnscreenChat, isCommand, shortenForPanel, wrapSendWithOnscreenEcho, type IncomingChatMessage, } from '../src/onscreen-chat'; import { renderTemplate } from '../src/templates'; import { createSend } from '../src/chat'; import { FakeChatSender, FakeSimClient } from './helpers'; import { startFakeSim, type FakeSimHandle } from './fake-sim'; const SRC_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'src'); function message(overrides: Partial = {}): IncomingChatMessage { return { login: 'mothra_fan', displayName: 'mothra_fan', messageText: 'go left!', ...overrides }; } function forwarder( options: { enabled?: boolean; clock?: FakeClock } = {}, ): { onscreen: OnscreenChat; sim: FakeSimClient } { const sim = new FakeSimClient(); const onscreen = new OnscreenChat({ sim, config: { featureOnscreenChat: options.enabled ?? true, botUser: 'flybridgebot' }, ...(options.clock ? { clock: options.clock } : {}), }); return { onscreen, sim }; } // -- forwarding viewer messages ---------------------------------------------------------------- void test('an ordinary viewer message is sanitized and posted to /chat', async () => { const { onscreen, sim } = forwarder(); assert.equal(await onscreen.forwardViewerMessage(message({ messageText: ' go LEFT! ' })), 'sent'); assert.deepEqual(sim.chatCalls, [{ by: 'mothra_fan', text: 'go LEFT!', bot: false }]); }); void test('commands are not chatter and never reach the sim', async () => { const { onscreen, sim } = forwarder(); for (const text of ['!sugar', ' !how', '!fly please', '!!!']) { assert.equal(await onscreen.forwardViewerMessage(message({ messageText: text })), 'command', text); } assert.deepEqual(sim.chatCalls, []); // A bang in the middle of a sentence is not a command. assert.equal(await onscreen.forwardViewerMessage(message({ messageText: 'go left!' })), 'sent'); }); void test('known third-party bots are dropped', async () => { const { onscreen, sim } = forwarder(); for (const login of KNOWN_BOT_LOGINS) { const outcome = await onscreen.forwardViewerMessage( message({ login: login.toUpperCase(), displayName: login, messageText: 'follow for more' }), ); assert.equal(outcome, 'bot', login); } assert.deepEqual(sim.chatCalls, []); }); void test('hostile messages are dropped before they leave the process', async () => { const { onscreen, sim } = forwarder(); const hostile = [ 'check out https://evil.example/pwn', 'www.evil.tv', 'discord.gg/abcd', 'zero​width', '‮gnippot ma I', 'nul', 'h́́́i', 'nice \u{1FAB0}', '', '`rm -rf /`', 'x'.repeat(5_000), ' ', '', ]; for (const text of hostile) { assert.equal( await onscreen.forwardViewerMessage(message({ messageText: text })), 'rejected', JSON.stringify(text.slice(0, 40)), ); } assert.deepEqual(sim.chatCalls, [], 'not one hostile line reached the sim client'); }); void test('a display name that fails validation is dropped rather than replaced', async () => { const { onscreen, sim } = forwarder(); // `validateDisplayName` would substitute the literal "a viewer"; for a chat line that would be // attributing someone's words to a fiction, so the line is dropped instead. const outcome = await onscreen.forwardViewerMessage( message({ login: 'weird', displayName: 'alex', messageText: 'hello' }), ); assert.equal(outcome, 'invalid_name'); assert.deepEqual(sim.chatCalls, []); }); void test('FEATURE_ONSCREEN_CHAT=false forwards nothing at all', async () => { const { onscreen, sim } = forwarder({ enabled: false }); assert.equal(onscreen.isEnabled, false); assert.equal(await onscreen.forwardViewerMessage(message()), 'disabled'); assert.equal(await onscreen.forwardBotReply('hello'), 'disabled'); assert.deepEqual(sim.chatCalls, []); }); void test('a sim that says no is normal; a sim that is not there is not', async () => { const { onscreen, sim } = forwarder(); for (const [result, expected] of [ [{ ok: false, kind: 'forbidden', error: 'chat is disabled' }, 'sim_refused'], [{ ok: false, kind: 'rate_limited', retryAfterMs: 1_200 }, 'sim_refused'], [{ ok: false, kind: 'http_error', status: 422, error: 'chat line refused: url' }, 'sim_refused'], [{ ok: false, kind: 'timeout' }, 'sim_unreachable'], [{ ok: false, kind: 'network_error', error: 'ECONNREFUSED' }, 'sim_unreachable'], ] as const) { sim.chatResult = result as typeof sim.chatResult; assert.equal(await onscreen.forwardViewerMessage(message()), expected, JSON.stringify(result)); } }); // -- the bridge's own replies ------------------------------------------------------------------ void test("the bridge's own replies are posted with bot: true", async () => { const { onscreen, sim } = forwarder(); assert.equal(await onscreen.forwardBotReply('Sugar is on cooldown - try again in 7s.'), 'sent'); assert.deepEqual(sim.chatCalls, [ { by: 'flybridgebot', text: 'Sugar is on cooldown - try again in 7s.', bot: true }, ]); }); void test('every template renders to something the sanitizer accepts', async () => { // A template the sanitizer refuses would silently vanish from the CHAT panel, which is exactly // the kind of quiet mismatch this test exists to catch (em dashes and smart quotes included). const { onscreen, sim } = forwarder(); const gameTitle = 'Pokemon Red'; const samples: Record = { startup: { channel: 'flyplayspokemon', gameTitle }, fly: { gameTitle }, brain: { gameTitle }, how: { gameTitle }, stuck: { label: 'Left the bedroom', durationLabel: '3m 12s' }, sugarAccepted: { by: 'fly_fan_42' }, sugarCooldown: { retryAfterSeconds: 7 }, sugarDisabled: {}, followThanks: { by: 'fly_fan_42' }, raidThanks: { by: 'fly_fan_42', viewers: 12 }, explainerConnectome: {}, explainerButtons: { gameTitle }, explainerReward: { gameTitle }, explainerSugar: {}, explainerHonesty: {}, explainerRepo: {}, }; for (const [id, params] of Object.entries(samples)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const text = renderTemplate(id as any, params as any); const outcome = await onscreen.forwardBotReply(text); assert.equal(outcome, 'sent', `template ${id} was refused: ${text}`); } assert.equal(sim.chatCalls.length, Object.keys(samples).length); assert.ok(sim.chatCalls.every((call) => call.bot === true)); }); void test('wrapSendWithOnscreenEcho sends to Twitch first and then to the screen', async () => { const sender = new FakeChatSender(); const { onscreen, sim } = forwarder(); const send = wrapSendWithOnscreenEcho(createSend(sender), onscreen, renderTemplate); await send('sugarAccepted', { by: 'fly_fan_42' }); assert.equal(sender.sent.length, 1); assert.equal(sim.chatCalls.length, 1); assert.equal(sim.chatCalls[0]?.text, sender.sent[0]); assert.equal(sim.chatCalls[0]?.bot, true); }); void test("the EventSub echo of our own reply is dropped, but an unrecognised one is not", async () => { const clock = new FakeClock(1_000); const { onscreen, sim } = forwarder({ clock }); await onscreen.forwardBotReply('Sugar is turned off right now.'); assert.equal(sim.chatCalls.length, 1); // Twitch echoes our own message back over `channel.chat.message`; posting it again would // double it on screen. const echo = message({ login: 'flybridgebot', displayName: 'flybridgebot', messageText: 'Sugar is turned off right now.', }); assert.equal(await onscreen.forwardViewerMessage(echo), 'echo'); assert.equal(sim.chatCalls.length, 1, 'nothing posted twice'); // A second, identical message is no longer a recognised echo — the memory is consumed once. assert.equal(await onscreen.forwardViewerMessage(echo), 'sent'); assert.equal(sim.chatCalls[1]?.bot, true, 'it still goes up marked as the bot'); // And an echo that arrives after the window has passed is treated as an ordinary bot line. await onscreen.forwardBotReply('Sugar is turned off right now.'); clock.advance(ECHO_WINDOW_MS + 1); assert.equal(await onscreen.forwardViewerMessage(echo), 'sent'); }); void test('an outcome counter is incremented for every decision', async () => { const counts = new Map(); const sim = new FakeSimClient(); const onscreen = new OnscreenChat({ sim, config: { featureOnscreenChat: true, botUser: 'flybridgebot' }, metrics: { increment: (name: string, by = 1) => counts.set(name, (counts.get(name) ?? 0) + by) }, }); await onscreen.forwardViewerMessage(message()); await onscreen.forwardViewerMessage(message({ messageText: '!sugar' })); await onscreen.forwardViewerMessage(message({ messageText: 'www.evil.tv' })); assert.equal(counts.get('flybridge_onscreen_chat_total{outcome="sent"}'), 1); assert.equal(counts.get('flybridge_onscreen_chat_total{outcome="command"}'), 1); assert.equal(counts.get('flybridge_onscreen_chat_total{outcome="rejected"}'), 1); }); // -- against the real HTTP contract ------------------------------------------------------------ async function withFakeSim(run: (sim: FakeSimHandle) => Promise): Promise { const fakeSim = await startFakeSim(); try { await run(fakeSim); } finally { await fakeSim.close(); } } void test('the sim client speaks the POST /chat contract', async () => { await withFakeSim(async (fakeSim) => { const client = new HttpSimClient({ baseUrl: fakeSim.baseUrl, timeoutMs: 1_000 }); fakeSim.queueChatResponse({ kind: 'accept', eventId: 77 }); assert.deepEqual(await client.chat({ by: 'alex', text: 'go left', bot: false }), { ok: true, data: { eventId: 77 }, }); assert.deepEqual(fakeSim.requests.at(-1), { path: '/chat', body: { by: 'alex', text: 'go left', bot: false }, }); fakeSim.queueChatResponse({ kind: 'refused', reason: 'url' }); assert.deepEqual(await client.chat({ by: 'alex', text: 'bit.ly' }), { ok: false, kind: 'http_error', status: 422, error: 'chat line refused: url', }); fakeSim.queueChatResponse({ kind: 'rate_limited', retryAfterMs: 1_500 }); assert.deepEqual(await client.chat({ by: 'alex', text: 'again' }), { ok: false, kind: 'rate_limited', retryAfterMs: 1_500, }); fakeSim.queueChatResponse({ kind: 'disabled' }); const forbidden = await client.chat({ by: 'alex', text: 'hello' }); assert.equal(forbidden.ok, false); if (!forbidden.ok) assert.equal(forbidden.kind, 'forbidden'); }); }); void test('a hostile flood against a real fake sim posts only the lines that survive', async () => { await withFakeSim(async (fakeSim) => { const client = new HttpSimClient({ baseUrl: fakeSim.baseUrl, timeoutMs: 1_000 }); const onscreen = new OnscreenChat({ sim: client, config: { featureOnscreenChat: true, botUser: 'flybridgebot' }, }); const flood: IncomingChatMessage[] = [ message({ messageText: 'go left!' }), message({ messageText: '!sugar' }), message({ login: 'nightbot', displayName: 'Nightbot', messageText: 'plug plug' }), message({ messageText: 'join discord.gg/abcd' }), message({ messageText: '‮evil' }), message({ messageText: 'the ledge — right there' }), message({ displayName: 'not a name', messageText: 'hello' }), ]; const outcomes = []; for (const item of flood) outcomes.push(await onscreen.forwardViewerMessage(item)); assert.deepEqual(outcomes, [ 'sent', 'command', 'bot', 'rejected', 'rejected', 'sent', 'invalid_name', ]); const posted = fakeSim.requests.filter((request) => request.path === '/chat'); assert.equal(posted.length, 2); assert.deepEqual( posted.map((request) => (request.body as { text: string }).text), ['go left!', 'the ledge — right there'], ); }); }); // -- Lint: no raw chat reaches a sim payload by any other route --------------------------------- function collectSourceFiles(dir: string): string[] { const files: string[] = []; for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) files.push(...collectSourceFiles(full)); else if (entry.endsWith('.ts')) files.push(full); } return files; } /** The one module allowed to sanitize and post chat, and the one allowed to read a message body. */ const CHAT_GATEWAY = 'onscreen-chat.ts'; void test('no raw chat reaches the sim payload: only the sanitized path exists', () => { const files = collectSourceFiles(SRC_DIR); assert.ok(files.length > 10, 'expected to find the bridge sources'); const readsMessageBody: string[] = []; const sanitizes: string[] = []; const postsChat: string[] = []; for (const file of files) { const name = relative(SRC_DIR, file); const source = readFileSync(file, 'utf8'); // Comments are prose about the rule, not code that breaks it. const code = source .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/^\s*\/\/.*$/gm, ''); if (/\bmessageText\b/.test(code)) readsMessageBody.push(name); if (/\bsanitizeChatText\b/.test(code)) sanitizes.push(name); if (/\.chat\(/.test(code)) postsChat.push(name); // Nothing, anywhere, may hand a message body to a sim call. const simCall = /\b(?:sim|simClient|this\.sim)\.(chat|stimulate|reward)\(([^;]*)/g; let match: RegExpExecArray | null; while ((match = simCall.exec(code)) !== null) { assert.ok( !/messageText|event\.message|\.text\b\s*\+|`/.test(match[2] ?? ''), `${name}: a sim call is built from a message body or a template literal: ${match[0]}`, ); } } // `commands.ts` reads a body only to parse a command out of its first token; `eventsub.ts` // hands the body to the gateway. Nothing else may see it. assert.deepEqual( readsMessageBody.sort(), ['commands.ts', 'eventsub.ts', CHAT_GATEWAY].sort(), 'a new module started reading chat message bodies', ); assert.deepEqual(sanitizes, [CHAT_GATEWAY], 'sanitizeChatText must have exactly one call site'); assert.deepEqual(postsChat, [CHAT_GATEWAY], 'POST /chat must have exactly one call site'); }); void test('the gateway posts only text that came out of the sanitizer', () => { const source = readFileSync(join(SRC_DIR, CHAT_GATEWAY), 'utf8'); // Every `sanitizeChatText(...)` result is bound to a const and checked for null before use. const bindings = [...source.matchAll(/const (\w+) = sanitizeChatText\(/g)].map((match) => match[1]); assert.ok(bindings.length >= 2, `expected the viewer and bot paths to sanitize: ${bindings}`); for (const binding of bindings) { assert.match( source, new RegExp(`if \\(${binding} === null\\) return`), `${binding} is used without a null check`, ); } // The only `text:` ever handed to `sim.chat` is the sanitized local, never a body or a literal. const payloads = [...source.matchAll(/\.chat\(\{([^}]*)\}\)/g)].map((match) => match[1] ?? ''); assert.ok(payloads.length >= 1, 'expected a sim.chat({...}) call site'); for (const payload of payloads) { assert.match(payload, /text\b/, payload); assert.ok(!/messageText/.test(payload), `raw chat in a /chat payload: ${payload}`); assert.ok(!/[`+]/.test(payload), `a built string in a /chat payload: ${payload}`); } }); void test('a long template reply is shortened for the panel, at a word boundary', () => { const short = 'Sugar is turned off right now.'; assert.equal(shortenForPanel(short), short, 'a line under the cap is untouched'); const long = renderTemplate('brain', { gameTitle: 'Pokemon Red' }); assert.ok([...long].length > 200, 'this template is longer than the cap; that is the case to cover'); const shortened = shortenForPanel(long); assert.ok([...shortened].length <= 200); assert.ok(shortened.endsWith('…')); assert.ok(!shortened.endsWith(' …'), 'no space before the ellipsis'); assert.ok(long.startsWith(shortened.slice(0, -1)), 'it is a prefix of the real reply'); // And what comes out still passes the sanitizer, which is the point of shortening at all. assert.equal(sanitizeChatText(shortened), shortened); // A single 300-character word has no boundary to cut at, and is still cut. const unbroken = 'a'.repeat(300); assert.equal([...shortenForPanel(unbroken)].length, 200); }); void test('a viewer line over the cap is refused, never trimmed', async () => { const { onscreen, sim } = forwarder(); assert.equal(await onscreen.forwardViewerMessage(message({ messageText: 'x'.repeat(201) })), 'rejected'); assert.deepEqual(sim.chatCalls, [], 'shortening is only ever applied to our own replies'); }); void test('isCommand is the same rule the command parser uses', () => { for (const text of ['!fly', ' !fly', '\t!how', '!']) assert.ok(isCommand(text), text); for (const text of ['go left!', 'wow!!', '', 'hi ! there']) assert.ok(!isCommand(text), text); });