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,37 +240,57 @@ 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",
); );
const inboundMessage: WebInboundMessage = {
id,
from,
conversationId: from,
to: selfE164 ?? "me",
accountId: access.resolvedAccountId,
body,
pushName: senderName,
timestamp,
chatType: group ? "group" : "direct",
chatId: remoteJid,
senderJid: participantJid,
senderE164: senderE164 ?? undefined,
senderName,
replyToId: replyContext?.id,
replyToBody: replyContext?.body,
replyToSender: replyContext?.sender,
groupSubject,
groupParticipants,
mentionedJids: mentionedJids ?? undefined,
selfJid,
selfE164,
location: location ?? undefined,
sendComposing,
reply,
sendMedia,
mediaPath,
mediaType,
};
try { try {
const task = Promise.resolve( const task = Promise.resolve(
options.onMessage({ (async () => {
id, // Apply debounce batching if configured
from, if (debounceWindowMs > 0) {
conversationId: from, const bufferKey = group ? remoteJid : from;
to: selfE164 ?? "me", const existing = messageBuffer.get(bufferKey);
accountId: access.resolvedAccountId, if (existing) {
body, if (existing.timeout) clearTimeout(existing.timeout);
pushName: senderName, existing.messages.push(inboundMessage);
timestamp, existing.timeout = setTimeout(() => processBufferedMessages(bufferKey), debounceWindowMs);
chatType: group ? "group" : "direct", } else {
chatId: remoteJid, messageBuffer.set(bufferKey, {
senderJid: participantJid, messages: [inboundMessage],
senderE164: senderE164 ?? undefined, timeout: setTimeout(() => processBufferedMessages(bufferKey), debounceWindowMs),
senderName, });
replyToId: replyContext?.id, }
replyToBody: replyContext?.body, } else {
replyToSender: replyContext?.sender, // No debouncing, process immediately
groupSubject, await options.onMessage(inboundMessage);
groupParticipants, }
mentionedJids: mentionedJids ?? undefined, })(),
selfJid,
selfE164,
location: location ?? undefined,
sendComposing,
reply,
sendMedia,
mediaPath,
mediaType,
}),
); );
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");