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:
juanpablodlc
2026-01-15 15:07:19 -08:00
committed by GitHub
parent 7dea403302
commit 4a99b9b651
26 changed files with 927 additions and 212 deletions

View File

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

View File

@@ -1,5 +1,7 @@
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
import { getReplyFromConfig } from "../../auto-reply/reply.js";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import { resolveInboundDebounceMs } from "../../auto-reply/inbound-debounce.js";
import { waitForever } from "../../cli/wait.js";
import { loadConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
@@ -169,12 +171,22 @@ export async function monitorWebChannel(
account,
});
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "whatsapp" });
const shouldDebounce = (msg: WebInboundMsg) => {
if (msg.mediaPath || msg.mediaType) return false;
if (msg.location) return false;
if (msg.replyToId || msg.replyToBody) return false;
return !hasControlCommand(msg.body, cfg);
};
const listener = await (listenerFactory ?? monitorWebInbox)({
verbose,
accountId: account.accountId,
authDir: account.authDir,
mediaMaxMb: account.mediaMaxMb,
sendReadReceipts: account.sendReadReceipts,
debounceMs: inboundDebounceMs,
shouldDebounce,
onMessage: async (msg: WebInboundMsg) => {
handledMessages += 1;
lastMessageAt = Date.now();

View File

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

View File

@@ -5,6 +5,7 @@ import { logVerbose, shouldLogVerbose } from "../../globals.js";
import { recordChannelActivity } from "../../infra/channel-activity.js";
import { createSubsystemLogger, getChildLogger } from "../../logging.js";
import { saveMediaBuffer } from "../../media/store.js";
import { createInboundDebouncer } from "../../auto-reply/inbound-debounce.js";
import { jidToE164, resolveJidToE164 } from "../../utils.js";
import { createWaSocket, getStatusCode, waitForWaConnection } from "../session.js";
import { checkInboundAccessControl } from "./access-control.js";
@@ -28,6 +29,10 @@ export async function monitorWebInbox(options: {
mediaMaxMb?: number;
/** Send read receipts for incoming messages (default true). */
sendReadReceipts?: boolean;
/** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */
debounceMs?: number;
/** Optional debounce gating predicate. */
shouldDebounce?: (msg: WebInboundMessage) => boolean;
}) {
const inboundLogger = getChildLogger({ module: "web-inbound" });
const inboundConsoleLog = createSubsystemLogger("gateway/channels/whatsapp").child("inbound");
@@ -56,6 +61,45 @@ export async function monitorWebInbox(options: {
const selfJid = sock.user?.id;
const selfE164 = selfJid ? jidToE164(selfJid) : null;
const debouncer = createInboundDebouncer<WebInboundMessage>({
debounceMs: options.debounceMs ?? 0,
buildKey: (msg) => {
const senderKey =
msg.chatType === "group"
? (msg.senderJid ?? msg.senderE164 ?? msg.senderName ?? msg.from)
: msg.from;
if (!senderKey) return null;
const conversationKey = msg.chatType === "group" ? msg.chatId : msg.from;
return `${msg.accountId}:${conversationKey}:${senderKey}`;
},
shouldDebounce: options.shouldDebounce,
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) return;
if (entries.length === 1) {
await options.onMessage(last);
return;
}
const mentioned = new Set<string>();
for (const entry of entries) {
for (const jid of entry.mentionedJids ?? []) mentioned.add(jid);
}
const combinedBody = entries
.map((entry) => entry.body)
.filter(Boolean)
.join("\n");
const combinedMessage: WebInboundMessage = {
...last,
body: combinedBody,
mentionedJids: mentioned.size > 0 ? Array.from(mentioned) : undefined,
};
await options.onMessage(combinedMessage);
},
onError: (err) => {
inboundLogger.error({ error: String(err) }, "failed handling inbound web message");
inboundConsoleLog.error(`Failed handling inbound web message: ${String(err)}`);
},
});
const groupMetaCache = new Map<
string,
{ subject?: string; participants?: string[]; expires: number }
@@ -217,38 +261,37 @@ export async function monitorWebInbox(options: {
{ from, to: selfE164 ?? "me", body, mediaPath, mediaType, timestamp },
"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 {
const task = Promise.resolve(
options.onMessage({
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,
}),
);
const task = Promise.resolve(debouncer.enqueue(inboundMessage));
void task.catch((err) => {
inboundLogger.error({ error: String(err) }, "failed handling inbound web message");
inboundConsoleLog.error(`Failed handling inbound web message: ${String(err)}`);