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

116 lines
4.5 KiB
TypeScript

/**
* Rotating explainer poster (`docs/design/stage-bridge.md` B2): every `explainerIntervalMs`
* (default 20 min) post the next of six explainer cards, unless chat has been silent for
* `explainerSilenceMs` (default 20 min) — "so it does not shout into an empty room."
*
* Driven by an injected `Clock` and an explicit `tick()` rather than real timers, so
* `tests/explainer.test.ts` can exercise the silence rule deterministically. `src/index.ts`
* calls `tick()` on a real interval and `noteChatActivity()` on every incoming chat message.
*/
import type { BridgeConfig } from './config';
import type { Send } from './chat';
import type { Clock } from './ratelimit';
import { systemClock } from './ratelimit';
import { EXPLAINER_TEMPLATE_IDS, type TemplateId } from './templates';
export interface ExplainerPosterOptions {
send: Send;
config: Pick<BridgeConfig, 'gameTitle'>;
intervalMs: number;
silenceMs: number;
clock?: Clock;
/**
* Whether the rotation runs at all. Defaults to `intervalMs > 0`, so an interval of zero is
* "off" without a caller having to say so twice; `src/index.ts` passes `explainerEnabled(config)`
* so `FEATURE_QUIET` turns it off too. A disabled poster still accepts `noteChatActivity()` —
* the call site is the chat handler, which should not have to know.
*/
enabled?: boolean;
}
/**
* Whether this deployment runs the explainer rotation at all.
*
* Two independent switches, per the operator 2026-09-17 ("make bot less chatty. speaks only when spoken
* to"): `FEATURE_QUIET` silences everything self-initiated, and `EXPLAINER_INTERVAL_MS=0` turns
* off just this rotation on a channel that still greets and posts notices.
*/
export function explainerEnabled(config: Pick<BridgeConfig, 'featureQuiet' | 'explainerIntervalMs'>): boolean {
return !config.featureQuiet && config.explainerIntervalMs > 0;
}
export class ExplainerPoster {
private readonly send: Send;
private readonly config: Pick<BridgeConfig, 'gameTitle'>;
private readonly intervalMs: number;
private readonly silenceMs: number;
private readonly clock: Clock;
private readonly enabled: boolean;
private cardIndex = 0;
private lastPostAt: number;
private lastChatActivityAt: number;
constructor(options: ExplainerPosterOptions) {
this.send = options.send;
this.config = options.config;
this.intervalMs = options.intervalMs;
this.silenceMs = options.silenceMs;
this.clock = options.clock ?? systemClock;
this.enabled = options.enabled ?? options.intervalMs > 0;
const now = this.clock.now();
this.lastPostAt = now;
this.lastChatActivityAt = now;
}
/** Call on every incoming chat message (any message, not just commands) to reset the silence clock. */
noteChatActivity(): void {
this.lastChatActivityAt = this.clock.now();
}
/**
* Call periodically. Posts the next rotation card and returns `true` if `intervalMs` has
* elapsed since the last post attempt and chat has not been silent for `silenceMs`; otherwise
* returns `false`. The interval always resets on a due check, silent or not, so a long quiet
* stretch does not burst-post once chat picks back up. A disabled poster (quiet mode, or
* `EXPLAINER_INTERVAL_MS=0`) always returns `false` and never sends.
*/
async tick(): Promise<boolean> {
if (!this.enabled) return false;
const now = this.clock.now();
if (now - this.lastPostAt < this.intervalMs) return false;
this.lastPostAt = now;
if (now - this.lastChatActivityAt >= this.silenceMs) return false;
const id = EXPLAINER_TEMPLATE_IDS[this.cardIndex % EXPLAINER_TEMPLATE_IDS.length]!;
this.cardIndex += 1;
await postExplainerCard(this.send, id, this.config);
return true;
}
}
async function postExplainerCard(send: Send, id: TemplateId, config: Pick<BridgeConfig, 'gameTitle'>): Promise<void> {
switch (id) {
case 'explainerConnectome':
await send('explainerConnectome', {});
return;
case 'explainerButtons':
await send('explainerButtons', { gameTitle: config.gameTitle });
return;
case 'explainerReward':
await send('explainerReward', { gameTitle: config.gameTitle });
return;
case 'explainerSugar':
await send('explainerSugar', {});
return;
case 'explainerHonesty':
await send('explainerHonesty', {});
return;
case 'explainerRepo':
await send('explainerRepo', {});
return;
default:
return; // unreachable: EXPLAINER_TEMPLATE_IDS only ever contains the cases above
}
}