feat(whatsapp): add debounceMs for batching rapid messages (#971)
* feat(whatsapp): add debounceMs for batching rapid messages
Add a `debounceMs` configuration option to WhatsApp channel settings
that batches rapid consecutive messages from the same sender into a
single response. This prevents triggering separate agent runs for
each message when a user sends multiple short messages in quick
succession (e.g., "Hey!", "how are you?", "I was wondering...").
Changes:
- Add `debounceMs` config to WhatsAppConfig and WhatsAppAccountConfig
- Implement message buffering in `monitorWebInbox` with:
- Map-based buffer keyed by sender (DM) or chat ID (groups)
- Debounce timer that resets on each new message
- Message combination with newline separator
- Single message optimization (no modification if only one message)
- Wire `debounceMs` through account resolution and monitor tuning
- Add UI hints and schema documentation
Usage example:
{
"channels": {
"whatsapp": {
"debounceMs": 5000 // 5 second window
}
}
}
Default behavior: `debounceMs: 0` (disabled by default)
Verified: All existing tests pass (3204 tests), TypeScript compilation
succeeds with no errors.
Implemented with assistance from AI coding tools.
Closes #967
* chore: wip inbound debounce
* fix: debounce inbound messages across channels (#971) (thanks @juanpablodlc)
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
// @ts-nocheck
|
||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||
import {
|
||||
createInboundDebouncer,
|
||||
resolveInboundDebounceMs,
|
||||
} from "../auto-reply/inbound-debounce.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { writeConfigFile } from "../config/io.js";
|
||||
import { danger, logVerbose, warn } from "../globals.js";
|
||||
@@ -43,6 +48,61 @@ export const registerTelegramHandlers = ({
|
||||
const textFragmentBuffer = new Map<string, TextFragmentEntry>();
|
||||
let textFragmentProcessing: Promise<void> = Promise.resolve();
|
||||
|
||||
const debounceMs = resolveInboundDebounceMs({ cfg, channel: "telegram" });
|
||||
type TelegramDebounceEntry = {
|
||||
ctx: unknown;
|
||||
msg: TelegramMessage;
|
||||
allMedia: Array<{ path: string; contentType?: string }>;
|
||||
storeAllowFrom: string[];
|
||||
debounceKey: string | null;
|
||||
botUsername?: string;
|
||||
};
|
||||
const inboundDebouncer = createInboundDebouncer<TelegramDebounceEntry>({
|
||||
debounceMs,
|
||||
buildKey: (entry) => entry.debounceKey,
|
||||
shouldDebounce: (entry) => {
|
||||
if (entry.allMedia.length > 0) return false;
|
||||
const text = entry.msg.text ?? entry.msg.caption ?? "";
|
||||
if (!text.trim()) return false;
|
||||
return !hasControlCommand(text, cfg, { botUsername: entry.botUsername });
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
const last = entries.at(-1);
|
||||
if (!last) return;
|
||||
if (entries.length === 1) {
|
||||
await processMessage(last.ctx, last.allMedia, last.storeAllowFrom);
|
||||
return;
|
||||
}
|
||||
const combinedText = entries
|
||||
.map((entry) => entry.msg.text ?? entry.msg.caption ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (!combinedText.trim()) return;
|
||||
const first = entries[0];
|
||||
const baseCtx = first.ctx as { me?: unknown; getFile?: unknown } & Record<string, unknown>;
|
||||
const getFile =
|
||||
typeof baseCtx.getFile === "function" ? baseCtx.getFile.bind(baseCtx) : async () => ({});
|
||||
const syntheticMessage: TelegramMessage = {
|
||||
...first.msg,
|
||||
text: combinedText,
|
||||
caption: undefined,
|
||||
caption_entities: undefined,
|
||||
entities: undefined,
|
||||
date: last.msg.date ?? first.msg.date,
|
||||
};
|
||||
const messageIdOverride = last.msg.message_id ? String(last.msg.message_id) : undefined;
|
||||
await processMessage(
|
||||
{ message: syntheticMessage, me: baseCtx.me, getFile },
|
||||
[],
|
||||
first.storeAllowFrom,
|
||||
messageIdOverride ? { messageIdOverride } : undefined,
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
runtime.error?.(danger(`telegram debounce flush failed: ${String(err)}`));
|
||||
},
|
||||
});
|
||||
|
||||
const processMediaGroup = async (entry: MediaGroupEntry) => {
|
||||
try {
|
||||
entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id);
|
||||
@@ -403,7 +463,20 @@ export const registerTelegramHandlers = ({
|
||||
throw mediaErr;
|
||||
}
|
||||
const allMedia = media ? [{ path: media.path, contentType: media.contentType }] : [];
|
||||
await processMessage(ctx, allMedia, storeAllowFrom);
|
||||
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||
const conversationKey =
|
||||
resolvedThreadId != null ? `${chatId}:topic:${resolvedThreadId}` : String(chatId);
|
||||
const debounceKey = senderId
|
||||
? `telegram:${accountId ?? "default"}:${conversationKey}:${senderId}`
|
||||
: null;
|
||||
await inboundDebouncer.enqueue({
|
||||
ctx,
|
||||
msg,
|
||||
allMedia,
|
||||
storeAllowFrom,
|
||||
debounceKey,
|
||||
botUsername: ctx.me?.username,
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`handler failed: ${String(err)}`));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user