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

@@ -168,6 +168,7 @@ const FIELD_LABELS: Record<string, string> = {
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
"messages.ackReaction": "Ack Reaction Emoji",
"messages.ackReactionScope": "Ack Reaction Scope",
"messages.inbound.debounceMs": "Inbound Message Debounce (ms)",
"talk.apiKey": "Talk API Key",
"channels.whatsapp": "WhatsApp",
"channels.telegram": "Telegram",
@@ -189,6 +190,7 @@ const FIELD_LABELS: Record<string, string> = {
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
"channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
"channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)",
"channels.signal.dmPolicy": "Signal DM Policy",
"channels.imessage.dmPolicy": "iMessage DM Policy",
"channels.discord.dm.policy": "Discord DM Policy",
@@ -327,6 +329,8 @@ const FIELD_HELP: Record<string, string> = {
"messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).",
"messages.ackReactionScope":
'When to send ack reactions ("group-mentions", "group-all", "direct", "all").',
"messages.inbound.debounceMs":
"Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).",
"channels.telegram.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].',
"channels.telegram.streamMode":
@@ -348,6 +352,8 @@ const FIELD_HELP: Record<string, string> = {
"channels.whatsapp.dmPolicy":
'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.debounceMs":
"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).",
"channels.signal.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].',
"channels.imessage.dmPolicy":

View File

@@ -17,6 +17,22 @@ export type QueueConfig = {
drop?: QueueDropPolicy;
};
export type InboundDebounceByProvider = {
whatsapp?: number;
telegram?: number;
discord?: number;
slack?: number;
signal?: number;
imessage?: number;
msteams?: number;
webchat?: number;
};
export type InboundDebounceConfig = {
debounceMs?: number;
byChannel?: InboundDebounceByProvider;
};
export type BroadcastStrategy = "parallel" | "sequential";
export type BroadcastConfig = {
@@ -64,6 +80,8 @@ export type MessagesConfig = {
responsePrefix?: string;
groupChat?: GroupChatConfig;
queue?: QueueConfig;
/** Debounce rapid inbound messages per sender (global + per-channel overrides). */
inbound?: InboundDebounceConfig;
/** Emoji reaction used to acknowledge inbound messages (empty disables). */
ackReaction?: string;
/** When to send ack reactions. Default: "group-mentions". */

View File

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

View File

@@ -188,6 +188,19 @@ export const QueueModeBySurfaceSchema = z
})
.optional();
export const DebounceMsBySurfaceSchema = z
.object({
whatsapp: z.number().int().nonnegative().optional(),
telegram: z.number().int().nonnegative().optional(),
discord: z.number().int().nonnegative().optional(),
slack: z.number().int().nonnegative().optional(),
signal: z.number().int().nonnegative().optional(),
imessage: z.number().int().nonnegative().optional(),
msteams: z.number().int().nonnegative().optional(),
webchat: z.number().int().nonnegative().optional(),
})
.optional();
export const QueueSchema = z
.object({
mode: QueueModeSchema.optional(),
@@ -198,6 +211,13 @@ export const QueueSchema = z
})
.optional();
export const InboundDebounceSchema = z
.object({
debounceMs: z.number().int().nonnegative().optional(),
byChannel: DebounceMsBySurfaceSchema,
})
.optional();
export const TranscribeAudioSchema = z
.object({
command: z.array(z.string()).superRefine((value, ctx) => {

View File

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

View File

@@ -1,6 +1,11 @@
import { z } from "zod";
import { GroupChatSchema, NativeCommandsSettingSchema, QueueSchema } from "./zod-schema.core.js";
import {
GroupChatSchema,
InboundDebounceSchema,
NativeCommandsSettingSchema,
QueueSchema,
} from "./zod-schema.core.js";
export const SessionSchema = z
.object({
@@ -54,6 +59,7 @@ export const MessagesSchema = z
responsePrefix: z.string().optional(),
groupChat: GroupChatSchema,
queue: QueueSchema,
inbound: InboundDebounceSchema,
ackReaction: z.string().optional(),
ackReactionScope: z.enum(["group-mentions", "group-all", "direct", "all"]).optional(),
removeAckAfterReply: z.boolean().optional(),