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
This commit is contained in:
juanpablodlc 2026-01-15 10:59:21 -08:00 committed by Peter Steinberger
parent d00f2d9c0c
commit c609be185e
7 changed files with 86 additions and 29 deletions

View File

@ -189,6 +189,7 @@ const FIELD_LABELS: Record<string, string> = {
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy", "channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
"channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
"channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)",
"channels.signal.dmPolicy": "Signal DM Policy", "channels.signal.dmPolicy": "Signal DM Policy",
"channels.imessage.dmPolicy": "iMessage DM Policy", "channels.imessage.dmPolicy": "iMessage DM Policy",
"channels.discord.dm.policy": "Discord DM Policy", "channels.discord.dm.policy": "Discord DM Policy",
@ -348,6 +349,8 @@ const FIELD_HELP: Record<string, string> = {
"channels.whatsapp.dmPolicy": "channels.whatsapp.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].', 'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].',
"channels.whatsapp.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).", "channels.whatsapp.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).",
"channels.whatsapp.debounceMs":
"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).",
"channels.signal.dmPolicy": "channels.signal.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].', 'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].',
"channels.imessage.dmPolicy": "channels.imessage.dmPolicy":

View File

@ -75,6 +75,8 @@ export type WhatsAppConfig = {
*/ */
group?: "always" | "mentions" | "never"; group?: "always" | "mentions" | "never";
}; };
/** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */
debounceMs?: number;
}; };
export type WhatsAppAccountConfig = { export type WhatsAppAccountConfig = {
@ -131,4 +133,6 @@ export type WhatsAppAccountConfig = {
*/ */
group?: "always" | "mentions" | "never"; group?: "always" | "mentions" | "never";
}; };
/** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */
debounceMs?: number;
}; };

View File

@ -46,6 +46,7 @@ export const WhatsAppAccountSchema = z
group: z.enum(["always", "mentions", "never"]).optional().default("mentions"), group: z.enum(["always", "mentions", "never"]).optional().default("mentions"),
}) })
.optional(), .optional(),
debounceMs: z.number().int().nonnegative().optional().default(0),
}) })
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return; if (value.dmPolicy !== "open") return;
@ -101,6 +102,7 @@ export const WhatsAppConfigSchema = z
group: z.enum(["always", "mentions", "never"]).optional().default("mentions"), group: z.enum(["always", "mentions", "never"]).optional().default("mentions"),
}) })
.optional(), .optional(),
debounceMs: z.number().int().nonnegative().optional().default(0),
}) })
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return; if (value.dmPolicy !== "open") return;

View File

@ -26,6 +26,7 @@ export type ResolvedWhatsAppAccount = {
blockStreaming?: boolean; blockStreaming?: boolean;
ackReaction?: WhatsAppAccountConfig["ackReaction"]; ackReaction?: WhatsAppAccountConfig["ackReaction"];
groups?: WhatsAppAccountConfig["groups"]; groups?: WhatsAppAccountConfig["groups"];
debounceMs?: number;
}; };
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] { function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
@ -153,6 +154,7 @@ export function resolveWhatsAppAccount(params: {
blockStreaming: accountCfg?.blockStreaming ?? rootCfg?.blockStreaming, blockStreaming: accountCfg?.blockStreaming ?? rootCfg?.blockStreaming,
ackReaction: accountCfg?.ackReaction ?? rootCfg?.ackReaction, ackReaction: accountCfg?.ackReaction ?? rootCfg?.ackReaction,
groups: accountCfg?.groups ?? rootCfg?.groups, groups: accountCfg?.groups ?? rootCfg?.groups,
debounceMs: accountCfg?.debounceMs ?? rootCfg?.debounceMs,
}; };
} }

View File

@ -175,6 +175,7 @@ export async function monitorWebChannel(
authDir: account.authDir, authDir: account.authDir,
mediaMaxMb: account.mediaMaxMb, mediaMaxMb: account.mediaMaxMb,
sendReadReceipts: account.sendReadReceipts, sendReadReceipts: account.sendReadReceipts,
debounceMs: tuning.debounceMs ?? account.debounceMs,
onMessage: async (msg: WebInboundMsg) => { onMessage: async (msg: WebInboundMsg) => {
handledMessages += 1; handledMessages += 1;
lastMessageAt = Date.now(); lastMessageAt = Date.now();

View File

@ -30,4 +30,6 @@ export type WebMonitorTuning = {
statusSink?: (status: WebChannelStatus) => void; statusSink?: (status: WebChannelStatus) => void;
/** WhatsApp account id. Default: "default". */ /** WhatsApp account id. Default: "default". */
accountId?: string; accountId?: string;
/** Debounce window (ms) for batching rapid consecutive messages from the same sender. */
debounceMs?: number;
}; };

View File

@ -28,6 +28,8 @@ export async function monitorWebInbox(options: {
mediaMaxMb?: number; mediaMaxMb?: number;
/** Send read receipts for incoming messages (default true). */ /** Send read receipts for incoming messages (default true). */
sendReadReceipts?: boolean; sendReadReceipts?: boolean;
/** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */
debounceMs?: number;
}) { }) {
const inboundLogger = getChildLogger({ module: "web-inbound" }); const inboundLogger = getChildLogger({ module: "web-inbound" });
const inboundConsoleLog = createSubsystemLogger("gateway/channels/whatsapp").child("inbound"); const inboundConsoleLog = createSubsystemLogger("gateway/channels/whatsapp").child("inbound");
@ -56,6 +58,27 @@ export async function monitorWebInbox(options: {
const selfJid = sock.user?.id; const selfJid = sock.user?.id;
const selfE164 = selfJid ? jidToE164(selfJid) : null; const selfE164 = selfJid ? jidToE164(selfJid) : null;
// Message batching for debounce
const debounceWindowMs = options.debounceMs ?? 0;
const messageBuffer = new Map<
string,
{ messages: WebInboundMessage[]; timeout: ReturnType<typeof setTimeout> | null }
>();
const processBufferedMessages = async (key: string) => {
const buffered = messageBuffer.get(key);
if (!buffered) return;
const { messages } = buffered;
messageBuffer.delete(key);
if (messages.length === 0) return;
if (messages.length === 1) {
await options.onMessage(messages[0]);
return;
}
const combinedBody = messages.map((m) => m.body).join("\n");
const combinedMessage: WebInboundMessage = { ...messages[0], body: combinedBody };
await options.onMessage(combinedMessage);
};
const groupMetaCache = new Map< const groupMetaCache = new Map<
string, string,
{ subject?: string; participants?: string[]; expires: number } { subject?: string; participants?: string[]; expires: number }
@ -217,9 +240,7 @@ export async function monitorWebInbox(options: {
{ from, to: selfE164 ?? "me", body, mediaPath, mediaType, timestamp }, { from, to: selfE164 ?? "me", body, mediaPath, mediaType, timestamp },
"inbound message", "inbound message",
); );
try { const inboundMessage: WebInboundMessage = {
const task = Promise.resolve(
options.onMessage({
id, id,
from, from,
conversationId: from, conversationId: from,
@ -247,7 +268,29 @@ export async function monitorWebInbox(options: {
sendMedia, sendMedia,
mediaPath, mediaPath,
mediaType, mediaType,
}), };
try {
const task = Promise.resolve(
(async () => {
// Apply debounce batching if configured
if (debounceWindowMs > 0) {
const bufferKey = group ? remoteJid : from;
const existing = messageBuffer.get(bufferKey);
if (existing) {
if (existing.timeout) clearTimeout(existing.timeout);
existing.messages.push(inboundMessage);
existing.timeout = setTimeout(() => processBufferedMessages(bufferKey), debounceWindowMs);
} else {
messageBuffer.set(bufferKey, {
messages: [inboundMessage],
timeout: setTimeout(() => processBufferedMessages(bufferKey), debounceWindowMs),
});
}
} else {
// No debouncing, process immediately
await options.onMessage(inboundMessage);
}
})(),
); );
void task.catch((err) => { void task.catch((err) => {
inboundLogger.error({ error: String(err) }, "failed handling inbound web message"); inboundLogger.error({ error: String(err) }, "failed handling inbound web message");