flybrain/services/bridge/src/onscreen-chat.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

265 lines
11 KiB
TypeScript

/**
* 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<BridgeConfig, 'featureOnscreenChat' | 'botUser'>;
metrics?: Pick<Metrics, 'increment'>;
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<string>;
private readonly metrics: Pick<Metrics, 'increment'> | 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<string, number>();
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<ForwardOutcome> {
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<ForwardOutcome> {
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<ForwardOutcome> {
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('<templateId>', ...)` 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<T extends TemplateId>(id: T, params: TemplateParams<T>): Promise<void> {
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 = <T extends TemplateId>(id: T, params: TemplateParams<T>) => string;