export interface MessageBatcherOptions { /** Default: 600. */ readonly debounceMs?: number; /** Default: 8. */ readonly maxMessages?: number; /** Default: 4000. */ readonly maxChars?: number; /** Default: 3500. */ readonly splitThreshold?: number; /** Default: 1200. */ readonly extendedDebounceMs?: number; } export type MessageBatcherCallback = (mergedText: string, key?: string) => Promise; interface PendingBatch { readonly texts: string[]; charCount: number; timer: ReturnType | null; } const DEFAULT_OPTIONS = { debounceMs: 600, maxMessages: 8, maxChars: 4000, splitThreshold: 3500, extendedDebounceMs: 1200, } as const; export class MessageBatcher { private readonly debounceMs: number; private readonly maxMessages: number; private readonly maxChars: number; private readonly splitThreshold: number; private readonly extendedDebounceMs: number; private readonly pending = new Map(); private readonly chains = new Map>(); constructor( private readonly onFlush: MessageBatcherCallback, options: MessageBatcherOptions = {}, ) { this.debounceMs = options.debounceMs ?? DEFAULT_OPTIONS.debounceMs; this.maxMessages = options.maxMessages ?? DEFAULT_OPTIONS.maxMessages; this.maxChars = options.maxChars ?? DEFAULT_OPTIONS.maxChars; this.splitThreshold = options.splitThreshold ?? DEFAULT_OPTIONS.splitThreshold; this.extendedDebounceMs = options.extendedDebounceMs ?? DEFAULT_OPTIONS.extendedDebounceMs; } async enqueue(chatId: string, senderOpenId: string, text: string): Promise { const key = messageBatchKey(chatId, senderOpenId); await this.runSerial(key, async () => { const existing = this.pending.get(key); const batch = existing ?? { texts: [], charCount: 0, timer: null, }; if (existing === undefined) { this.pending.set(key, batch); } batch.texts.push(text); batch.charCount += text.length + (batch.texts.length === 1 ? 0 : 1); if (batch.texts.length >= this.maxMessages || batch.charCount >= this.maxChars) { await this.flushPendingBatch(key, batch); return; } this.scheduleFlush(key, batch, text.length >= this.splitThreshold ? this.extendedDebounceMs : this.debounceMs); }); } async flushNow(key: string): Promise { await this.runSerial(key, async () => { const batch = this.pending.get(key); if (batch === undefined) return; await this.flushPendingBatch(key, batch); }); } async flushAll(): Promise { const keys = [...this.pending.keys()]; await Promise.all(keys.map((key) => this.flushNow(key))); } private scheduleFlush(key: string, batch: PendingBatch, delayMs: number): void { if (batch.timer !== null) { clearTimeout(batch.timer); } batch.timer = setTimeout(() => { void this.flushNow(key).catch(() => undefined); }, delayMs); (batch.timer as { unref?: () => void }).unref?.(); } private async flushPendingBatch(key: string, batch: PendingBatch): Promise { if (batch.timer !== null) { clearTimeout(batch.timer); batch.timer = null; } if (this.pending.get(key) !== batch) return; this.pending.delete(key); await this.onFlush(batch.texts.join("\n"), key); } private runSerial(key: string, operation: () => Promise): Promise { const previous = this.chains.get(key) ?? Promise.resolve(); const next = previous.catch(() => undefined).then(operation); this.chains.set(key, next); return next.finally(() => { if (this.chains.get(key) === next) { this.chains.delete(key); } }); } } export function messageBatchKey(chatId: string, senderOpenId: string): string { return `${chatId}:${senderOpenId}`; }