diff --git a/CHANGELOG.md b/CHANGELOG.md index 10935941f..5dad1c15c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Telegram: add `/whoami` + `/id` commands to reveal sender id for allowlists. - Telegram/Onboarding: allow `@username` and prefixed ids in `allowFrom` prompts (with stability warning). - Control UI: stop auto-writing `telegram.groups["*"]` and warn/confirm before enabling wildcard groups. +- WhatsApp: send ack reactions only for handled messages and ignore legacy `messages.ackReaction` (doctor copies to `whatsapp.ackReaction`). (#629) — thanks @pasogott. ## 2026.1.11-6 diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md index e425adb17..5adce09b8 100644 --- a/docs/providers/whatsapp.md +++ b/docs/providers/whatsapp.md @@ -159,6 +159,55 @@ Behavior: - WhatsApp Web sends standard messages (no quoted reply threading in the current gateway). - Reply tags are ignored on this provider. +## Acknowledgment reactions (auto-react on receipt) + +WhatsApp can automatically send emoji reactions to incoming messages immediately upon receipt, before the bot generates a reply. This provides instant feedback to users that their message was received. + +**Configuration:** +```json +{ + "whatsapp": { + "ackReaction": { + "emoji": "👀", + "direct": true, + "group": "mentions" + } + } +} +``` + +**Options:** +- `emoji` (string): Emoji to use for acknowledgment (e.g., "👀", "✅", "📨"). Empty or omitted = feature disabled. +- `direct` (boolean, default: `true`): Send reactions in direct/DM chats. +- `group` (string, default: `"mentions"`): Group chat behavior: + - `"always"`: React to all group messages (even without @mention) + - `"mentions"`: React only when bot is @mentioned + - `"never"`: Never react in groups + +**Per-account override:** +```json +{ + "whatsapp": { + "accounts": { + "work": { + "ackReaction": { + "emoji": "✅", + "direct": false, + "group": "always" + } + } + } + } +} +``` + +**Behavior notes:** +- Reactions are sent **immediately** upon message receipt, before typing indicators or bot replies. +- In groups with `requireMention: false` (activation: always), `group: "mentions"` will react to all messages (not just @mentions). +- Fire-and-forget: reaction failures are logged but don't prevent the bot from replying. +- Participant JID is automatically included for group reactions. +- WhatsApp ignores `messages.ackReaction`; use `whatsapp.ackReaction` instead. + ## Agent tool (reactions) - Tool: `whatsapp` with `react` action (`chatJid`, `messageId`, `emoji`, optional `remove`). - Optional: `participant` (group sender), `fromMe` (reacting to your own message), `accountId` (multi-account). @@ -205,8 +254,10 @@ Behavior: - `whatsapp.selfChatMode` (same-phone setup; bot uses your personal WhatsApp number). - `whatsapp.allowFrom` (DM allowlist). - `whatsapp.mediaMaxMb` (inbound media save cap). +- `whatsapp.ackReaction` (auto-reaction on message receipt: `{emoji, direct, group}`). - `whatsapp.accounts..*` (per-account settings + optional `authDir`). - `whatsapp.accounts..mediaMaxMb` (per-account inbound media cap). +- `whatsapp.accounts..ackReaction` (per-account ack reaction override). - `whatsapp.groupAllowFrom` (group sender allowlist). - `whatsapp.groupPolicy` (group policy). - `whatsapp.historyLimit` / `whatsapp.accounts..historyLimit` (group history context; `0` disables). diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 39206d9e3..c8fe4bdf3 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -40,8 +40,10 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe Text + native (when enabled): - `/help` - `/commands` -- `/status` (show current status; includes a short usage line when available; alias: `/usage`) -- `/whoami` (show your sender id; alias: `/id`) +- `/status` +- `/status` (show current status; includes a short usage line when available) +- `/usage` (alias: `/status`) +- `/whoami` (alias: `/id`) - `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`) - `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`) - `/cost on|off` (toggle per-response usage line) diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index 4e359b675..4e0312b51 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -251,6 +251,39 @@ export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): { } } + const legacyAckReaction = cfg.messages?.ackReaction?.trim(); + if (legacyAckReaction) { + const hasWhatsAppAck = cfg.whatsapp?.ackReaction !== undefined; + if (!hasWhatsAppAck) { + const legacyScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + let direct = true; + let group: "always" | "mentions" | "never" = "mentions"; + if (legacyScope === "all") { + direct = true; + group = "always"; + } else if (legacyScope === "direct") { + direct = true; + group = "never"; + } else if (legacyScope === "group-all") { + direct = false; + group = "always"; + } else if (legacyScope === "group-mentions") { + direct = false; + group = "mentions"; + } + next = { + ...next, + whatsapp: { + ...next.whatsapp, + ackReaction: { emoji: legacyAckReaction, direct, group }, + }, + }; + changes.push( + `Copied messages.ackReaction → whatsapp.ackReaction (scope: ${legacyScope}).`, + ); + } + } + return { config: next, changes }; } diff --git a/src/config/types.ts b/src/config/types.ts index 545566495..235177962 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -169,6 +169,21 @@ export type WhatsAppConfig = { requireMention?: boolean; } >; + /** Acknowledgment reaction sent immediately upon message receipt. */ + ackReaction?: { + /** Emoji to use for acknowledgment (e.g., "👀"). Empty = disabled. */ + emoji?: string; + /** Send reactions in direct chats. Default: true. */ + direct?: boolean; + /** + * Send reactions in group chats: + * - "always": react to all group messages + * - "mentions": react only when bot is mentioned + * - "never": never react in groups + * Default: "mentions" + */ + group?: "always" | "mentions" | "never"; + }; }; export type WhatsAppAccountConfig = { @@ -202,6 +217,21 @@ export type WhatsAppAccountConfig = { requireMention?: boolean; } >; + /** Acknowledgment reaction sent immediately upon message receipt. */ + ackReaction?: { + /** Emoji to use for acknowledgment (e.g., "👀"). Empty = disabled. */ + emoji?: string; + /** Send reactions in direct chats. Default: true. */ + direct?: boolean; + /** + * Send reactions in group chats: + * - "always": react to all group messages + * - "mentions": react only when bot is mentioned + * - "never": never react in groups + * Default: "mentions" + */ + group?: "always" | "mentions" | "never"; + }; }; export type BrowserProfileConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 0d38a8c64..d7fd6d04b 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -1375,6 +1375,16 @@ export const ClawdbotSchema = z .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(), }) .superRefine((value, ctx) => { if (value.dmPolicy !== "open") return; @@ -1421,6 +1431,16 @@ export const ClawdbotSchema = z .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(), }) .superRefine((value, ctx) => { if (value.dmPolicy !== "open") return; diff --git a/src/web/accounts.ts b/src/web/accounts.ts index 4d71c2a0f..6536ae4ed 100644 --- a/src/web/accounts.ts +++ b/src/web/accounts.ts @@ -26,6 +26,7 @@ export type ResolvedWhatsAppAccount = { textChunkLimit?: number; mediaMaxMb?: number; blockStreaming?: boolean; + ackReaction?: WhatsAppAccountConfig["ackReaction"]; groups?: WhatsAppAccountConfig["groups"]; }; @@ -129,6 +130,7 @@ export function resolveWhatsAppAccount(params: { mediaMaxMb: accountCfg?.mediaMaxMb ?? params.cfg.whatsapp?.mediaMaxMb, blockStreaming: accountCfg?.blockStreaming ?? params.cfg.whatsapp?.blockStreaming, + ackReaction: accountCfg?.ackReaction ?? params.cfg.whatsapp?.ackReaction, groups: accountCfg?.groups ?? params.cfg.whatsapp?.groups, }; } diff --git a/src/web/auto-reply.ack-reaction.test.ts b/src/web/auto-reply.ack-reaction.test.ts new file mode 100644 index 000000000..83dffe491 --- /dev/null +++ b/src/web/auto-reply.ack-reaction.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from "vitest"; +import type { ClawdbotConfig } from "../config/types.js"; + +describe("WhatsApp ack reaction logic", () => { + // Helper to simulate the logic from auto-reply.ts + function shouldSendReaction( + cfg: ClawdbotConfig, + msg: { + id?: string; + chatType: "direct" | "group"; + wasMentioned?: boolean; + }, + groupActivation?: "always" | "mention", + ): boolean { + const ackConfig = cfg.whatsapp?.ackReaction; + const emoji = (ackConfig?.emoji ?? "").trim(); + const directEnabled = ackConfig?.direct ?? true; + const groupMode = ackConfig?.group ?? "mentions"; + + if (!emoji) return false; + if (!msg.id) return false; + + // Direct chat logic + if (msg.chatType === "direct") { + return directEnabled; + } + + // Group chat logic + if (msg.chatType === "group") { + if (groupMode === "never") return false; + if (groupMode === "always") return true; + if (groupMode === "mentions") { + // If group activation is "always", always react + if (groupActivation === "always") return true; + // Otherwise, only react if bot was mentioned + return msg.wasMentioned === true; + } + } + + return false; + } + + describe("direct chat", () => { + it("should react when direct=true", () => { + const cfg: ClawdbotConfig = { + whatsapp: { ackReaction: { emoji: "👀", direct: true } }, + }; + expect( + shouldSendReaction(cfg, { + id: "msg1", + chatType: "direct", + }), + ).toBe(true); + }); + + it("should not react when direct=false", () => { + const cfg: ClawdbotConfig = { + whatsapp: { ackReaction: { emoji: "👀", direct: false } }, + }; + expect( + shouldSendReaction(cfg, { + id: "msg1", + chatType: "direct", + }), + ).toBe(false); + }); + + it("should not react when emoji is empty", () => { + const cfg: ClawdbotConfig = { + whatsapp: { ackReaction: { emoji: "", direct: true } }, + }; + expect( + shouldSendReaction(cfg, { + id: "msg1", + chatType: "direct", + }), + ).toBe(false); + }); + + it("should not react when message id is missing", () => { + const cfg: ClawdbotConfig = { + whatsapp: { ackReaction: { emoji: "👀", direct: true } }, + }; + expect( + shouldSendReaction(cfg, { + chatType: "direct", + }), + ).toBe(false); + }); + }); + + describe("group chat - always mode", () => { + it("should react to all messages when group=always", () => { + const cfg: ClawdbotConfig = { + whatsapp: { ackReaction: { emoji: "👀", group: "always" } }, + }; + expect( + shouldSendReaction(cfg, { + id: "msg1", + chatType: "group", + wasMentioned: false, + }), + ).toBe(true); + }); + + it("should react even with mention when group=always", () => { + const cfg: ClawdbotConfig = { + whatsapp: { ackReaction: { emoji: "👀", group: "always" } }, + }; + expect( + shouldSendReaction(cfg, { + id: "msg1", + chatType: "group", + wasMentioned: true, + }), + ).toBe(true); + }); + }); + + describe("group chat - mentions mode", () => { + it("should react when mentioned", () => { + const cfg: ClawdbotConfig = { + whatsapp: { ackReaction: { emoji: "👀", group: "mentions" } }, + }; + expect( + shouldSendReaction(cfg, { + id: "msg1", + chatType: "group", + wasMentioned: true, + }), + ).toBe(true); + }); + + it("should not react when not mentioned", () => { + const cfg: ClawdbotConfig = { + whatsapp: { ackReaction: { emoji: "👀", group: "mentions" } }, + }; + expect( + shouldSendReaction( + cfg, + { + id: "msg1", + chatType: "group", + wasMentioned: false, + }, + "mention", // group activation + ), + ).toBe(false); + }); + + it("should react to all messages when group activation is always", () => { + const cfg: ClawdbotConfig = { + whatsapp: { ackReaction: { emoji: "👀", group: "mentions" } }, + }; + expect( + shouldSendReaction( + cfg, + { + id: "msg1", + chatType: "group", + wasMentioned: false, + }, + "always", // group has requireMention=false + ), + ).toBe(true); + }); + }); + + describe("group chat - never mode", () => { + it("should not react even with mention", () => { + const cfg: ClawdbotConfig = { + whatsapp: { ackReaction: { emoji: "👀", group: "never" } }, + }; + expect( + shouldSendReaction(cfg, { + id: "msg1", + chatType: "group", + wasMentioned: true, + }), + ).toBe(false); + }); + + it("should not react without mention", () => { + const cfg: ClawdbotConfig = { + whatsapp: { ackReaction: { emoji: "👀", group: "never" } }, + }; + expect( + shouldSendReaction(cfg, { + id: "msg1", + chatType: "group", + wasMentioned: false, + }), + ).toBe(false); + }); + }); + + describe("combinations", () => { + it("direct=false, group=always: only groups", () => { + const cfg: ClawdbotConfig = { + whatsapp: { + ackReaction: { emoji: "✅", direct: false, group: "always" }, + }, + }; + + expect(shouldSendReaction(cfg, { id: "m1", chatType: "direct" })).toBe( + false, + ); + + expect( + shouldSendReaction(cfg, { + id: "m2", + chatType: "group", + wasMentioned: false, + }), + ).toBe(true); + }); + + it("direct=true, group=never: only direct", () => { + const cfg: ClawdbotConfig = { + whatsapp: { + ackReaction: { emoji: "🤖", direct: true, group: "never" }, + }, + }; + + expect(shouldSendReaction(cfg, { id: "m1", chatType: "direct" })).toBe( + true, + ); + + expect( + shouldSendReaction(cfg, { + id: "m2", + chatType: "group", + wasMentioned: true, + }), + ).toBe(false); + }); + }); + + describe("defaults", () => { + it("should default direct=true", () => { + const cfg: ClawdbotConfig = { + whatsapp: { ackReaction: { emoji: "👀" } }, + }; + expect(shouldSendReaction(cfg, { id: "m1", chatType: "direct" })).toBe( + true, + ); + }); + + it("should default group=mentions", () => { + const cfg: ClawdbotConfig = { + whatsapp: { ackReaction: { emoji: "👀" } }, + }; + + expect( + shouldSendReaction(cfg, { + id: "m1", + chatType: "group", + wasMentioned: false, + }), + ).toBe(false); + + expect( + shouldSendReaction(cfg, { + id: "m2", + chatType: "group", + wasMentioned: true, + }), + ).toBe(true); + }); + }); + + describe("legacy config is ignored", () => { + it("does not use messages.ackReaction for WhatsApp", () => { + const cfg: ClawdbotConfig = { + messages: { ackReaction: "👀", ackReactionScope: "all" }, + }; + expect( + shouldSendReaction(cfg, { + id: "m1", + chatType: "direct", + }), + ).toBe(false); + }); + }); +}); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index c41f6de11..6b3c4adc0 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -68,7 +68,7 @@ import { resolveWhatsAppAccount } from "./accounts.js"; import { setActiveWebListener } from "./active-listener.js"; import { monitorWebInbox } from "./inbound.js"; import { loadWebMedia } from "./media.js"; -import { sendMessageWhatsApp } from "./outbound.js"; +import { sendMessageWhatsApp, sendReactionWhatsApp } from "./outbound.js"; import { computeBackoff, newConnectionId, @@ -826,6 +826,7 @@ export async function monitorWebProvider( ...baseCfg, whatsapp: { ...baseCfg.whatsapp, + ackReaction: account.ackReaction, messagePrefix: account.messagePrefix, allowFrom: account.allowFrom, groupAllowFrom: account.groupAllowFrom, @@ -1199,6 +1200,64 @@ export async function monitorWebProvider( return false; } + // Send ack reaction immediately upon message receipt (post-gating) + if (msg.id) { + const ackConfig = cfg.whatsapp?.ackReaction; + const emoji = (ackConfig?.emoji ?? "").trim(); + const directEnabled = ackConfig?.direct ?? true; + const groupMode = ackConfig?.group ?? "mentions"; + const conversationIdForCheck = msg.conversationId ?? msg.from; + + const shouldSendReaction = () => { + if (!emoji) return false; + + if (msg.chatType === "direct") { + return directEnabled; + } + + if (msg.chatType === "group") { + if (groupMode === "never") return false; + if (groupMode === "always") return true; + if (groupMode === "mentions") { + const activation = resolveGroupActivationFor({ + agentId: route.agentId, + sessionKey: route.sessionKey, + conversationId: conversationIdForCheck, + }); + if (activation === "always") return true; + return msg.wasMentioned === true; + } + } + + return false; + }; + + if (shouldSendReaction()) { + replyLogger.info( + { chatId: msg.chatId, messageId: msg.id, emoji }, + "sending ack reaction", + ); + sendReactionWhatsApp(msg.chatId, msg.id, emoji, { + verbose, + fromMe: false, + participant: msg.senderJid, + accountId: route.accountId, + }).catch((err) => { + replyLogger.warn( + { + error: formatError(err), + chatId: msg.chatId, + messageId: msg.id, + }, + "failed to send ack reaction", + ); + logVerbose( + `WhatsApp ack reaction failed for chat ${msg.chatId}: ${formatError(err)}`, + ); + }); + } + } + const correlationId = msg.id ?? newConnectionId(); replyLogger.info( {