Files
curriculum-project-hub/hub/src/feishu/messageBatcher.ts
T
hongjr03 ad65d93b2e feat: add message burst debouncing (MessageBatcher)
- Create MessageBatcher: debounce 600ms, adaptive delay for long chunks,
  max 8 messages/4000 chars before immediate flush, per (chatId, sender) key
- Integrate into trigger: text messages enqueued, slash commands and
  file/image bypass batching, locked projects respond immediately
- Add unit tests (8) for batcher lifecycle and edge cases
2026-07-08 16:40:39 +08:00

126 lines
3.8 KiB
TypeScript

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<void>;
interface PendingBatch {
readonly texts: string[];
charCount: number;
timer: ReturnType<typeof setTimeout> | 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<string, PendingBatch>();
private readonly chains = new Map<string, Promise<void>>();
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<void> {
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<void> {
await this.runSerial(key, async () => {
const batch = this.pending.get(key);
if (batch === undefined) return;
await this.flushPendingBatch(key, batch);
});
}
async flushAll(): Promise<void> {
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<void> {
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<void>): Promise<void> {
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}`;
}