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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
Reference in New Issue
Block a user