Files
clawdbot/src/config/zod-schema.providers-whatsapp.ts
juanpablodlc 4a99b9b651 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>
2026-01-15 23:07:19 +00:00

118 lines
4.2 KiB
TypeScript

import { z } from "zod";
import {
BlockStreamingCoalesceSchema,
DmConfigSchema,
DmPolicySchema,
GroupPolicySchema,
} from "./zod-schema.core.js";
export const WhatsAppAccountSchema = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
configWrites: z.boolean().optional(),
enabled: z.boolean().optional(),
sendReadReceipts: z.boolean().optional(),
messagePrefix: z.string().optional(),
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
authDir: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
selfChatMode: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
groups: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
})
.optional(),
)
.optional(),
ackReaction: z
.object({
emoji: z.string().optional(),
direct: z.boolean().optional().default(true),
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;
const allow = (value.allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message: 'channels.whatsapp.accounts.*.dmPolicy="open" requires allowFrom to include "*"',
});
});
export const WhatsAppConfigSchema = z
.object({
accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(),
capabilities: z.array(z.string()).optional(),
configWrites: z.boolean().optional(),
sendReadReceipts: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
messagePrefix: z.string().optional(),
selfChatMode: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().int().positive().optional().default(50),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
actions: z
.object({
reactions: z.boolean().optional(),
sendMessage: z.boolean().optional(),
polls: z.boolean().optional(),
})
.optional(),
groups: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
})
.optional(),
)
.optional(),
ackReaction: z
.object({
emoji: z.string().optional(),
direct: z.boolean().optional().default(true),
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;
const allow = (value.allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'channels.whatsapp.dmPolicy="open" requires channels.whatsapp.allowFrom to include "*"',
});
});