/** * Pure per-chat timer registry with an injectable clock. * * The manager needs several independent timers, all "armed after the first * interlocutor message" (plan): the continuation/priority window (~1:30) and * the owner-reply window (~5 min). This registry is the reusable core — a thin * wall-clock driver (setInterval → {@link TimerRegistry.collectDue}) sits on top * in the manager runtime. Keeping the model pure makes it trivial to test with * a fake clock. */ export interface Clock { now(): number; } export const systemClock: Clock = { now: () => Date.now() }; /** A settable fake clock for tests. */ export class ManualClock implements Clock { constructor(private t = 0) {} now(): number { return this.t; } advance(ms: number): void { this.t += ms; } set(ms: number): void { this.t = ms; } } export interface TimerEntry { chatId: string; name: string; dueAt: number; } const SEP = ""; export class TimerRegistry { private readonly entries = new Map(); constructor(private readonly clock: Clock = systemClock) {} private key(chatId: string, name: string): string { return `${chatId}${SEP}${name}`; } /** Arm (or re-arm, resetting) a timer to fire `delayMs` from now. */ arm(chatId: string, name: string, delayMs: number): void { this.entries.set(this.key(chatId, name), { chatId, name, dueAt: this.clock.now() + delayMs, }); } cancel(chatId: string, name: string): void { this.entries.delete(this.key(chatId, name)); } /** Cancel every timer for a chat (e.g. when a chat is closed/cleared). */ cancelChat(chatId: string): void { const prefix = `${chatId}${SEP}`; for (const key of [...this.entries.keys()]) { if (key.startsWith(prefix)) { this.entries.delete(key); } } } isArmed(chatId: string, name: string): boolean { return this.entries.has(this.key(chatId, name)); } /** Milliseconds until the timer fires, or null if not armed. */ remaining(chatId: string, name: string): number | null { const entry = this.entries.get(this.key(chatId, name)); return entry ? entry.dueAt - this.clock.now() : null; } /** Pop and return every timer that is due (`dueAt <= now`). */ collectDue(): TimerEntry[] { const now = this.clock.now(); const due: TimerEntry[] = []; for (const [key, entry] of this.entries) { if (entry.dueAt <= now) { due.push(entry); this.entries.delete(key); } } return due; } get size(): number { return this.entries.size; } } /** Well-known timer names used by the manager. */ export const TIMER = { /** Continuation/priority window; if the interlocutor replies within it we * keep their chat active and reset the timer. */ continueWindow: "continue-window", /** Owner-reply window; if the owner does not reply within it, the bot * (re)engages the chat. */ ownerReply: "owner-reply", } as const;