Files
clawdbot/src/web/accounts.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

166 lines
5.7 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthDir } from "../config/paths.js";
import type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import { resolveUserPath } from "../utils.js";
import { hasWebCredsSync } from "./auth-store.js";
export type ResolvedWhatsAppAccount = {
accountId: string;
name?: string;
enabled: boolean;
sendReadReceipts: boolean;
messagePrefix?: string;
authDir: string;
isLegacyAuthDir: boolean;
selfChatMode?: boolean;
allowFrom?: string[];
groupAllowFrom?: string[];
groupPolicy?: GroupPolicy;
dmPolicy?: DmPolicy;
textChunkLimit?: number;
mediaMaxMb?: number;
blockStreaming?: boolean;
ackReaction?: WhatsAppAccountConfig["ackReaction"];
groups?: WhatsAppAccountConfig["groups"];
debounceMs?: number;
};
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = cfg.channels?.whatsapp?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listWhatsAppAuthDirs(cfg: ClawdbotConfig): string[] {
const oauthDir = resolveOAuthDir();
const whatsappDir = path.join(oauthDir, "whatsapp");
const authDirs = new Set<string>([oauthDir, path.join(whatsappDir, DEFAULT_ACCOUNT_ID)]);
const accountIds = listConfiguredAccountIds(cfg);
for (const accountId of accountIds) {
authDirs.add(resolveWhatsAppAuthDir({ cfg, accountId }).authDir);
}
try {
const entries = fs.readdirSync(whatsappDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
authDirs.add(path.join(whatsappDir, entry.name));
}
} catch {
// ignore missing dirs
}
return Array.from(authDirs);
}
export function hasAnyWhatsAppAuth(cfg: ClawdbotConfig): boolean {
return listWhatsAppAuthDirs(cfg).some((authDir) => hasWebCredsSync(authDir));
}
export function listWhatsAppAccountIds(cfg: ClawdbotConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultWhatsAppAccountId(cfg: ClawdbotConfig): string {
const ids = listWhatsAppAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): WhatsAppAccountConfig | undefined {
const accounts = cfg.channels?.whatsapp?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
const entry = accounts[accountId] as WhatsAppAccountConfig | undefined;
return entry;
}
function resolveDefaultAuthDir(accountId: string): string {
return path.join(resolveOAuthDir(), "whatsapp", accountId);
}
function resolveLegacyAuthDir(): string {
// Legacy Baileys creds lived in the same directory as OAuth tokens.
return resolveOAuthDir();
}
function legacyAuthExists(authDir: string): boolean {
try {
return fs.existsSync(path.join(authDir, "creds.json"));
} catch {
return false;
}
}
export function resolveWhatsAppAuthDir(params: { cfg: ClawdbotConfig; accountId: string }): {
authDir: string;
isLegacy: boolean;
} {
const accountId = params.accountId.trim() || DEFAULT_ACCOUNT_ID;
const account = resolveAccountConfig(params.cfg, accountId);
const configured = account?.authDir?.trim();
if (configured) {
return { authDir: resolveUserPath(configured), isLegacy: false };
}
const defaultDir = resolveDefaultAuthDir(accountId);
if (accountId === DEFAULT_ACCOUNT_ID) {
const legacyDir = resolveLegacyAuthDir();
if (legacyAuthExists(legacyDir) && !legacyAuthExists(defaultDir)) {
return { authDir: legacyDir, isLegacy: true };
}
}
return { authDir: defaultDir, isLegacy: false };
}
export function resolveWhatsAppAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedWhatsAppAccount {
const rootCfg = params.cfg.channels?.whatsapp;
const accountId = params.accountId?.trim() || resolveDefaultWhatsAppAccountId(params.cfg);
const accountCfg = resolveAccountConfig(params.cfg, accountId);
const enabled = accountCfg?.enabled !== false;
const { authDir, isLegacy } = resolveWhatsAppAuthDir({
cfg: params.cfg,
accountId,
});
return {
accountId,
name: accountCfg?.name?.trim() || undefined,
enabled,
sendReadReceipts: accountCfg?.sendReadReceipts ?? rootCfg?.sendReadReceipts ?? true,
messagePrefix:
accountCfg?.messagePrefix ?? rootCfg?.messagePrefix ?? params.cfg.messages?.messagePrefix,
authDir,
isLegacyAuthDir: isLegacy,
selfChatMode: accountCfg?.selfChatMode ?? rootCfg?.selfChatMode,
dmPolicy: accountCfg?.dmPolicy ?? rootCfg?.dmPolicy,
allowFrom: accountCfg?.allowFrom ?? rootCfg?.allowFrom,
groupAllowFrom: accountCfg?.groupAllowFrom ?? rootCfg?.groupAllowFrom,
groupPolicy: accountCfg?.groupPolicy ?? rootCfg?.groupPolicy,
textChunkLimit: accountCfg?.textChunkLimit ?? rootCfg?.textChunkLimit,
mediaMaxMb: accountCfg?.mediaMaxMb ?? rootCfg?.mediaMaxMb,
blockStreaming: accountCfg?.blockStreaming ?? rootCfg?.blockStreaming,
ackReaction: accountCfg?.ackReaction ?? rootCfg?.ackReaction,
groups: accountCfg?.groups ?? rootCfg?.groups,
debounceMs: accountCfg?.debounceMs ?? rootCfg?.debounceMs,
};
}
export function listEnabledWhatsAppAccounts(cfg: ClawdbotConfig): ResolvedWhatsAppAccount[] {
return listWhatsAppAccountIds(cfg)
.map((accountId) => resolveWhatsAppAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}