/** * Forwarding chat to the screen (`docs/stream-mvp-plan.md`, "Rail layout v2": a persistent CHAT * panel of the last ~7 lines, AutoMod-passed, service-sanitized, deny-listed, kill-switched). * * The whole path, and what each step is responsible for: * * ``` * Twitch -> AutoMod -> flybridge (this module) -> POST /chat -> flysim (sanitize, deny list, * ring) -> feed header -> page * ``` * * **AutoMod comes first, and nothing here can substitute for it.** A message held by AutoMod never * arrives over EventSub at all: `channel.chat.message` fires for messages that were *delivered* to * chat, so a held message produces no event, and a message a moderator later deletes produces a * separate `channel.chat.message_delete` we do not subscribe to. Everything this module sees has * already passed the channel's moderation settings. * * What this module adds on top: * - `validateDisplayName`, the same chokepoint viewer names already go through (`src/names.ts`); * - `sanitizeChatText`, the same rules flysim enforces again on arrival (`@flybrain/feed`); * - commands are not chatter: a line starting with `!` is a bot instruction and is dropped here, * so `!sugar` does not appear on the broadcast twice (once as a command, once as a line); * - known third-party bots are dropped, because a bot talking to a bot is not audience; * - our own replies go to the screen from the wrapped sender (`wrapSendWithOnscreenEcho`) with * `bot: true`, marked as the bot's, and the EventSub echo of that same message is dropped as a * duplicate. A reply longer than the sanitizer's cap is shortened for the panel only * ([`shortenForPanel`]); its echo is over the cap and so is refused anyway, which is the same * outcome by a different door. * * Nothing here is authoritative: flysim re-runs every rule and applies the deny list and the rate * limits regardless of what this module does (`docs/control-api.md`). This is the cheap first pass, * so hostile text never even leaves the process. * * `FEATURE_ONSCREEN_CHAT=false` turns the forwarding off without touching flysim's own kill switch. */ import { CHAT_MAX_TEXT_LENGTH, FALLBACK_DISPLAY_NAME, sanitizeChatText, validateDisplayName, } from '@flybrain/feed'; import type { BridgeConfig } from './config'; import type { Send } from './chat'; import type { Metrics } from './health'; import type { Clock } from './ratelimit'; import { systemClock } from './ratelimit'; import type { SimClient } from './sim'; import type { TemplateId, TemplateParams } from './templates'; /** * Third-party chat bots whose output is not audience chatter. * * Logins, lower-cased. Our own bot account is added at construction from `config.botUser`, so it * does not have to be listed here and the two demo channels can use different bot accounts. */ export const KNOWN_BOT_LOGINS: readonly string[] = [ 'nightbot', 'streamelements', 'streamlabs', 'moobot', 'fossabot', 'wizebot', 'botisimo', 'phantombot', 'sery_bot', 'soundalerts', 'own3d', 'commanderroot', 'streamlabs_chatbot', ]; /** How long a self-posted line is remembered, so its EventSub echo is recognised as a duplicate. */ export const ECHO_WINDOW_MS = 15_000; /** What happened to one message. Everything but `sent` means nothing was posted to flysim. */ export type ForwardOutcome = | 'sent' | 'disabled' | 'command' | 'bot' | 'echo' | 'invalid_name' | 'rejected' | 'sim_refused' | 'sim_unreachable'; /** One incoming chat message, as much of it as this module is allowed to care about. */ export interface IncomingChatMessage { /** `chatterUserLogin` from EventSub, or the display name lower-cased if that is all there is. */ login: string; /** `chatterDisplayName` from EventSub. */ displayName: string; /** The message body. This is the only place raw chat text exists in this service. */ messageText: string; } export interface OnscreenChatOptions { sim: SimClient; config: Pick; metrics?: Pick; clock?: Clock; /** Extra bot logins to drop, on top of `KNOWN_BOT_LOGINS`. */ extraBotLogins?: readonly string[]; } /** * Forwards viewer messages and the bridge's own replies to `POST /chat`. * * Stateless apart from the echo memory, and twurple-free: `src/eventsub.ts` hands it a plain * `IncomingChatMessage`, so `tests/onscreen-chat.test.ts` drives the same code the stream does. */ export class OnscreenChat { private readonly sim: SimClient; private readonly enabled: boolean; private readonly botLogin: string; private readonly botLogins: Set; private readonly metrics: Pick | undefined; private readonly clock: Clock; /** Sanitized text of lines this bridge posted itself, with the wall clock it posted them at. */ private readonly recentSelfPosts = new Map(); constructor(options: OnscreenChatOptions) { this.sim = options.sim; this.enabled = options.config.featureOnscreenChat; this.botLogin = options.config.botUser.toLowerCase(); this.botLogins = new Set([ ...KNOWN_BOT_LOGINS, ...(options.extraBotLogins ?? []).map((login) => login.toLowerCase()), ]); this.metrics = options.metrics; this.clock = options.clock ?? systemClock; } /** Whether `FEATURE_ONSCREEN_CHAT` is on. flysim has its own, independent kill switch. */ get isEnabled(): boolean { return this.enabled; } /** * Forward one viewer message, if every rule here allows it. * * Never throws: a sim that is down is a dropped line and a counter, not an exception into * twurple's event loop. */ async forwardViewerMessage(message: IncomingChatMessage): Promise { if (!this.enabled) return this.count('disabled'); const login = message.login.toLowerCase(); // A command is an instruction to this bot, not something the audience said. if (isCommand(message.messageText)) return this.count('command'); const ownMessage = login === this.botLogin; if (!ownMessage && this.botLogins.has(login)) return this.count('bot'); const by = validateDisplayName(message.displayName); if (by === FALLBACK_DISPLAY_NAME && message.displayName !== FALLBACK_DISPLAY_NAME) { // flysim would refuse this name too; not worth a round trip. return this.count('invalid_name'); } const text = sanitizeChatText(message.messageText); if (text === null) return this.count('rejected'); // Our own replies reach the screen from `send()`, so the EventSub echo of one we already // posted is a duplicate. An echo we do NOT recognise (a post that failed, or a human using // the bot account) still goes up, marked as the bot's. if (ownMessage && this.consumeEcho(text)) return this.count('echo'); return this.post(by, text, ownMessage); } /** * Put one of the bridge's own template replies on the screen, marked `bot: true`. * * Called by [`wrapSendWithOnscreenEcho`] after the message went to Twitch, so a line only * appears on the broadcast if chat actually got it. */ async forwardBotReply(text: string): Promise { if (!this.enabled) return this.count('disabled'); const sanitized = sanitizeChatText(shortenForPanel(text)); if (sanitized === null) return this.count('rejected'); this.rememberSelfPost(sanitized); return this.post(validateDisplayName(this.botLogin), sanitized, true); } private async post(by: string, text: string, bot: boolean): Promise { const result = await this.sim.chat({ by, text, bot }); if (result.ok) return this.count('sent'); // 403 (flysim's own kill switch), 422 (a rule refused it) and 429 (a rate limit) are all // "the service said no", and all normal. Anything else means the sim is not answering. if (result.kind === 'forbidden' || result.kind === 'rate_limited' || result.kind === 'http_error') { return this.count('sim_refused'); } return this.count('sim_unreachable'); } private rememberSelfPost(text: string): void { this.pruneEchoes(); this.recentSelfPosts.set(text, this.clock.now()); } /** Whether `text` is one this bridge posted recently; consumed, so a repeat is not swallowed. */ private consumeEcho(text: string): boolean { this.pruneEchoes(); if (!this.recentSelfPosts.has(text)) return false; this.recentSelfPosts.delete(text); return true; } private pruneEchoes(): void { const now = this.clock.now(); for (const [text, at] of this.recentSelfPosts) { if (now - at > ECHO_WINDOW_MS) this.recentSelfPosts.delete(text); } } private count(outcome: ForwardOutcome): ForwardOutcome { this.metrics?.increment(`flybridge_onscreen_chat_total{outcome="${outcome}"}`); return outcome; } } /** Whether a message is a bot command rather than chatter. */ export function isCommand(messageText: string): boolean { return messageText.trimStart().startsWith('!'); } /** * Shorten one of **our own** template replies to the sanitizer's cap, at a word boundary, with an * ellipsis. * * Several templates are longer than 200 characters — fine for Twitch chat, over the cap for the * on-screen panel — and a reply that vanished from the panel would be a silent gap next to the * viewer line it was answering. This only ever touches text this service wrote itself * (`renderTemplate`, `src/templates.ts`); a viewer's line over the cap is refused whole, never * trimmed, because editing what someone said is worse than dropping it. * * The Twitch message is unaffected: it is sent in full before this runs. */ export function shortenForPanel(text: string, limit = CHAT_MAX_TEXT_LENGTH): string { const characters = [...text]; if (characters.length <= limit) return text; const head = characters.slice(0, limit - 1).join(''); const lastSpace = head.lastIndexOf(' '); const cut = lastSpace > limit / 2 ? head.slice(0, lastSpace) : head; return `${cut.trimEnd()}…`; } /** * Wrap a `send()` so every template reply that reaches Twitch also reaches the screen with * `bot: true`. * * The wrapper keeps `send`'s exact signature, so the template lint in `tests/templates.test.ts` * still sees `send('', ...)` call sites and the only way to produce an outbound string * is still `renderTemplate` (`src/templates.ts`). */ export function wrapSendWithOnscreenEcho(send: Send, onscreen: OnscreenChat, renderer: Renderer): Send { return async function sendAndEcho(id: T, params: TemplateParams): Promise { await send(id, params); await onscreen.forwardBotReply(renderer(id, params)); }; } /** `renderTemplate`, as a type the wrapper can take without importing the whole module. */ export type Renderer = (id: T, params: TemplateParams) => string;