* 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>
118 lines
4.2 KiB
TypeScript
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 "*"',
|
|
});
|
|
});
|