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

@@ -1,3 +1,8 @@
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
import type { ResolvedSlackAccount } from "../accounts.js";
import type { SlackMessageEvent } from "../types.js";
import type { SlackMonitorContext } from "./context.js";
@@ -14,6 +19,66 @@ export function createSlackMessageHandler(params: {
account: ResolvedSlackAccount;
}): SlackMessageHandler {
const { ctx, account } = params;
const debounceMs = resolveInboundDebounceMs({ cfg: ctx.cfg, channel: "slack" });
const debouncer = createInboundDebouncer<{
message: SlackMessageEvent;
opts: { source: "message" | "app_mention"; wasMentioned?: boolean };
}>({
debounceMs,
buildKey: (entry) => {
const senderId = entry.message.user ?? entry.message.bot_id;
if (!senderId) return null;
const threadKey = entry.message.thread_ts
? `${entry.message.channel}:${entry.message.thread_ts}`
: entry.message.channel;
return `slack:${ctx.accountId}:${threadKey}:${senderId}`;
},
shouldDebounce: (entry) => {
const text = entry.message.text ?? "";
if (!text.trim()) return false;
if (entry.message.files && entry.message.files.length > 0) return false;
return !hasControlCommand(text, ctx.cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) return;
const combinedText =
entries.length === 1
? (last.message.text ?? "")
: entries
.map((entry) => entry.message.text ?? "")
.filter(Boolean)
.join("\n");
const combinedMentioned = entries.some((entry) => Boolean(entry.opts.wasMentioned));
const syntheticMessage: SlackMessageEvent = {
...last.message,
text: combinedText,
};
const prepared = await prepareSlackMessage({
ctx,
account,
message: syntheticMessage,
opts: {
...last.opts,
wasMentioned: combinedMentioned || last.opts.wasMentioned,
},
});
if (!prepared) return;
if (entries.length > 1) {
const ids = entries.map((entry) => entry.message.ts).filter(Boolean) as string[];
if (ids.length > 0) {
prepared.ctxPayload.MessageSids = ids;
prepared.ctxPayload.MessageSidFirst = ids[0];
prepared.ctxPayload.MessageSidLast = ids[ids.length - 1];
}
}
await dispatchPreparedSlackMessage(prepared);
},
onError: (err) => {
ctx.runtime.error?.(`slack inbound debounce flush failed: ${String(err)}`);
},
});
return async (message, opts) => {
if (opts.source === "message" && message.type !== "message") return;
@@ -26,8 +91,6 @@ export function createSlackMessageHandler(params: {
return;
}
if (ctx.markMessageSeen(message.channel, message.ts)) return;
const prepared = await prepareSlackMessage({ ctx, account, message, opts });
if (!prepared) return;
await dispatchPreparedSlackMessage(prepared);
await debouncer.enqueue({ message, opts });
};
}