openclaw/src/auto-reply/templating.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

119 lines
3.5 KiB
TypeScript

import type { ChannelId } from "../channels/plugins/types.js";
import type { InternalMessageChannel } from "../utils/message-channel.js";
import type { CommandArgs } from "./commands-registry.types.js";
/** Valid message channels for routing. */
export type OriginatingChannelType = ChannelId | InternalMessageChannel;
export type MsgContext = {
Body?: string;
/**
* Raw message body without structural context (history, sender labels).
* Legacy alias for CommandBody. Falls back to Body if not set.
*/
RawBody?: string;
/**
* Prefer for command detection; RawBody is treated as legacy alias.
*/
CommandBody?: string;
CommandArgs?: CommandArgs;
From?: string;
To?: string;
SessionKey?: string;
/** Provider account id (multi-account). */
AccountId?: string;
ParentSessionKey?: string;
MessageSid?: string;
MessageSids?: string[];
MessageSidFirst?: string;
MessageSidLast?: string;
ReplyToId?: string;
ReplyToBody?: string;
ReplyToSender?: string;
ThreadStarterBody?: string;
ThreadLabel?: string;
MediaPath?: string;
MediaUrl?: string;
MediaType?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
Transcript?: string;
ChatType?: string;
GroupSubject?: string;
GroupRoom?: string;
GroupSpace?: string;
GroupMembers?: string;
GroupSystemPrompt?: string;
SenderName?: string;
SenderId?: string;
SenderUsername?: string;
SenderTag?: string;
SenderE164?: string;
/** Provider label (e.g. whatsapp, telegram). */
Provider?: string;
/** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */
Surface?: string;
WasMentioned?: boolean;
CommandAuthorized?: boolean;
CommandSource?: "text" | "native";
CommandTargetSessionKey?: string;
/** Thread identifier (Telegram topic id or Matrix thread event id). */
MessageThreadId?: string | number;
/** Telegram forum supergroup marker. */
IsForum?: boolean;
/**
* Originating channel for reply routing.
* When set, replies should be routed back to this provider
* instead of using lastChannel from the session.
*/
OriginatingChannel?: OriginatingChannelType;
/**
* Originating destination for reply routing.
* The chat/channel/user ID where the reply should be sent.
*/
OriginatingTo?: string;
};
export type TemplateContext = MsgContext & {
BodyStripped?: string;
SessionId?: string;
IsNewSession?: string;
};
function formatTemplateValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
if (typeof value === "symbol" || typeof value === "function") {
return value.toString();
}
if (Array.isArray(value)) {
return value
.flatMap((entry) => {
if (entry == null) return [];
if (typeof entry === "string") return [entry];
if (typeof entry === "number" || typeof entry === "boolean" || typeof entry === "bigint") {
return [String(entry)];
}
return [];
})
.join(",");
}
if (typeof value === "object") {
return "";
}
return "";
}
// Simple {{Placeholder}} interpolation using inbound message context.
export function applyTemplate(str: string | undefined, ctx: TemplateContext) {
if (!str) return "";
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
const value = ctx[key as keyof TemplateContext];
return formatTemplateValue(value);
});
}