From b3b507c6ea5ca7664b14f2b0ae48f42219b44699 Mon Sep 17 00:00:00 2001 From: sheeek Date: Fri, 9 Jan 2026 14:55:28 +0100 Subject: [PATCH 1/7] feat(whatsapp): add ack reaction support after successful replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add automatic emoji reactions on inbound WhatsApp messages - Support all ackReactionScope modes: all, direct, group-all, group-mentions - Reaction is sent AFTER successful reply (unlike Telegram/Discord) - Errors are logged with proper context - Add comprehensive test suite for ack reaction logic Config usage: messages: ackReaction: "👀" ackReactionScope: "group-mentions" # default Closes: WhatsApp ack-reaction feature request --- src/web/auto-reply.ack-reaction.test.ts | 266 ++++++++++++++++++++++++ src/web/auto-reply.ts | 41 +++- 2 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 src/web/auto-reply.ack-reaction.test.ts 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..922c0b353 --- /dev/null +++ b/src/web/auto-reply.ack-reaction.test.ts @@ -0,0 +1,266 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ClawdbotConfig } from "../config/types.js"; + +describe("WhatsApp ack reaction", () => { + const mockSendReaction = vi.fn(async () => {}); + const mockGetReply = vi.fn(async () => ({ + payloads: [{ text: "test reply" }], + meta: {}, + })); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should send ack reaction in direct chat when scope is 'all'", async () => { + const cfg: ClawdbotConfig = { + messages: { + ackReaction: "👀", + ackReactionScope: "all", + }, + }; + + // Simulate the logic from auto-reply.ts + const msg = { + id: "msg123", + chatId: "123456789@s.whatsapp.net", + chatType: "direct" as const, + from: "+1234567890", + to: "+9876543210", + body: "hello", + }; + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const didSendReply = true; + + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return msg.chatType === "direct"; + if (ackReactionScope === "group-all") return msg.chatType === "group"; + if (ackReactionScope === "group-mentions") { + if (msg.chatType !== "group") return false; + return false; // Would check wasMentioned + } + return false; + }; + + expect(shouldAckReaction()).toBe(true); + }); + + it("should send ack reaction in direct chat when scope is 'direct'", async () => { + const cfg: ClawdbotConfig = { + messages: { + ackReaction: "👀", + ackReactionScope: "direct", + }, + }; + + const msg = { + id: "msg123", + chatId: "123456789@s.whatsapp.net", + chatType: "direct" as const, + from: "+1234567890", + to: "+9876543210", + body: "hello", + }; + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const didSendReply = true; + + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return msg.chatType === "direct"; + if (ackReactionScope === "group-all") return msg.chatType === "group"; + return false; + }; + + expect(shouldAckReaction()).toBe(true); + }); + + it("should NOT send ack reaction in group when scope is 'direct'", async () => { + const cfg: ClawdbotConfig = { + messages: { + ackReaction: "👀", + ackReactionScope: "direct", + }, + }; + + const msg = { + id: "msg123", + chatId: "123456789-group@g.us", + chatType: "group" as const, + from: "123456789-group@g.us", + to: "+9876543210", + body: "hello", + wasMentioned: true, + }; + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const didSendReply = true; + + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return msg.chatType === "direct"; + if (ackReactionScope === "group-all") return msg.chatType === "group"; + return false; + }; + + expect(shouldAckReaction()).toBe(false); + }); + + it("should send ack reaction in group when mentioned and scope is 'group-mentions'", async () => { + const cfg: ClawdbotConfig = { + messages: { + ackReaction: "👀", + ackReactionScope: "group-mentions", + }, + }; + + const msg = { + id: "msg123", + chatId: "123456789-group@g.us", + chatType: "group" as const, + from: "123456789-group@g.us", + to: "+9876543210", + body: "hello @bot", + wasMentioned: true, + }; + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const didSendReply = true; + const requireMention = true; // Simulated from activation check + + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return msg.chatType === "direct"; + if (ackReactionScope === "group-all") return msg.chatType === "group"; + if (ackReactionScope === "group-mentions") { + if (msg.chatType !== "group") return false; + if (!requireMention) return false; + return msg.wasMentioned === true; + } + return false; + }; + + expect(shouldAckReaction()).toBe(true); + }); + + it("should NOT send ack reaction in group when NOT mentioned and scope is 'group-mentions'", async () => { + const cfg: ClawdbotConfig = { + messages: { + ackReaction: "👀", + ackReactionScope: "group-mentions", + }, + }; + + const msg = { + id: "msg123", + chatId: "123456789-group@g.us", + chatType: "group" as const, + from: "123456789-group@g.us", + to: "+9876543210", + body: "hello", + wasMentioned: false, + }; + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const didSendReply = true; + const requireMention = true; + + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return msg.chatType === "direct"; + if (ackReactionScope === "group-all") return msg.chatType === "group"; + if (ackReactionScope === "group-mentions") { + if (msg.chatType !== "group") return false; + if (!requireMention) return false; + return msg.wasMentioned === true; + } + return false; + }; + + expect(shouldAckReaction()).toBe(false); + }); + + it("should NOT send ack reaction when no reply was sent", async () => { + const cfg: ClawdbotConfig = { + messages: { + ackReaction: "👀", + ackReactionScope: "all", + }, + }; + + const msg = { + id: "msg123", + chatId: "123456789@s.whatsapp.net", + chatType: "direct" as const, + from: "+1234567890", + to: "+9876543210", + body: "hello", + }; + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const didSendReply = false; // No reply sent + + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + return true; + }; + + expect(shouldAckReaction()).toBe(false); + }); + + it("should NOT send ack reaction when ackReaction is empty", async () => { + const cfg: ClawdbotConfig = { + messages: { + ackReaction: "", + ackReactionScope: "all", + }, + }; + + const msg = { + id: "msg123", + chatId: "123456789@s.whatsapp.net", + chatType: "direct" as const, + from: "+1234567890", + to: "+9876543210", + body: "hello", + }; + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const didSendReply = true; + + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + return true; + }; + + expect(shouldAckReaction()).toBe(false); + }); +}); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index c41f6de11..f3a77eed9 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, @@ -1387,6 +1387,45 @@ export async function monitorWebProvider( groupHistories.set(groupHistoryKey, []); } + // Send ack reaction after successful reply + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return msg.chatType === "direct"; + if (ackReactionScope === "group-all") return msg.chatType === "group"; + if (ackReactionScope === "group-mentions") { + if (msg.chatType !== "group") return false; + const activation = resolveGroupActivationFor({ + agentId: route.agentId, + sessionKey: route.sessionKey, + conversationId, + }); + const requireMention = activation !== "always"; + if (!requireMention) return false; + return msg.wasMentioned === true; + } + return false; + }; + + if (shouldAckReaction() && msg.id) { + sendReactionWhatsApp(msg.chatId, msg.id, ackReaction, { + verbose, + fromMe: false, + }).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)}`, + ); + }); + } + return didSendReply; }; From c3587d6cae0b2f6d2351c053aa883ea8967b950f Mon Sep 17 00:00:00 2001 From: sheeek Date: Fri, 9 Jan 2026 18:02:33 +0100 Subject: [PATCH 2/7] fix(whatsapp): ack reaction logic for group activation 'always' mode - Fix bug where ack reaction was not sent when group activation is 'always' - When requireMention=false (activation: always), always send reaction - Add test case for activation='always' scenario - Update inline comments for clarity --- src/web/auto-reply.ack-reaction.test.ts | 53 +++++++++++++++++++++++-- src/web/auto-reply.ts | 4 +- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/web/auto-reply.ack-reaction.test.ts b/src/web/auto-reply.ack-reaction.test.ts index 922c0b353..bad6a583f 100644 --- a/src/web/auto-reply.ack-reaction.test.ts +++ b/src/web/auto-reply.ack-reaction.test.ts @@ -120,7 +120,7 @@ describe("WhatsApp ack reaction", () => { expect(shouldAckReaction()).toBe(false); }); - it("should send ack reaction in group when mentioned and scope is 'group-mentions'", async () => { + it("should send ack reaction in group when mentioned and scope is 'group-mentions' (requireMention=true)", async () => { const cfg: ClawdbotConfig = { messages: { ackReaction: "👀", @@ -152,7 +152,9 @@ describe("WhatsApp ack reaction", () => { if (ackReactionScope === "group-all") return msg.chatType === "group"; if (ackReactionScope === "group-mentions") { if (msg.chatType !== "group") return false; - if (!requireMention) return false; + // If mention is not required (activation === "always"), always react + if (!requireMention) return true; + // Otherwise, only react if bot was mentioned return msg.wasMentioned === true; } return false; @@ -161,7 +163,49 @@ describe("WhatsApp ack reaction", () => { expect(shouldAckReaction()).toBe(true); }); - it("should NOT send ack reaction in group when NOT mentioned and scope is 'group-mentions'", async () => { + it("should send ack reaction in group when requireMention=false and scope is 'group-mentions' (activation: always)", async () => { + const cfg: ClawdbotConfig = { + messages: { + ackReaction: "👀", + ackReactionScope: "group-mentions", + }, + }; + + const msg = { + id: "msg123", + chatId: "123456789-group@g.us", + chatType: "group" as const, + from: "123456789-group@g.us", + to: "+9876543210", + body: "hello", + wasMentioned: false, // No mention, but activation is "always" + }; + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const didSendReply = true; + const requireMention = false; // activation === "always" + + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (!msg.id) return false; + if (!didSendReply) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return msg.chatType === "direct"; + if (ackReactionScope === "group-all") return msg.chatType === "group"; + if (ackReactionScope === "group-mentions") { + if (msg.chatType !== "group") return false; + // If mention is not required (activation === "always"), always react + if (!requireMention) return true; + return msg.wasMentioned === true; + } + return false; + }; + + expect(shouldAckReaction()).toBe(true); + }); + + it("should NOT send ack reaction in group when NOT mentioned and scope is 'group-mentions' (requireMention=true)", async () => { const cfg: ClawdbotConfig = { messages: { ackReaction: "👀", @@ -193,7 +237,8 @@ describe("WhatsApp ack reaction", () => { if (ackReactionScope === "group-all") return msg.chatType === "group"; if (ackReactionScope === "group-mentions") { if (msg.chatType !== "group") return false; - if (!requireMention) return false; + // If mention is not required (activation === "always"), always react + if (!requireMention) return true; return msg.wasMentioned === true; } return false; diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index f3a77eed9..08552ad13 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -1405,7 +1405,9 @@ export async function monitorWebProvider( conversationId, }); const requireMention = activation !== "always"; - if (!requireMention) return false; + // If mention is not required (activation === "always"), always react + if (!requireMention) return true; + // Otherwise, only react if bot was mentioned return msg.wasMentioned === true; } return false; From d38b232724375c1d7e0b11f31b6b95ff642fffa0 Mon Sep 17 00:00:00 2001 From: sheeek Date: Fri, 9 Jan 2026 23:47:50 +0100 Subject: [PATCH 3/7] chore: fix linting issues in ack-reaction feature - Remove unused mock variables in tests - Remove unused ackReactionScope variables in simple test cases - Fix line length for ackReactionScope declaration - All lint checks passing (0 warnings, 0 errors) - All tests passing (8/8) --- src/web/auto-reply.ack-reaction.test.ts | 8 -------- src/web/auto-reply.ts | 3 ++- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/web/auto-reply.ack-reaction.test.ts b/src/web/auto-reply.ack-reaction.test.ts index bad6a583f..96471ad76 100644 --- a/src/web/auto-reply.ack-reaction.test.ts +++ b/src/web/auto-reply.ack-reaction.test.ts @@ -2,12 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../config/types.js"; describe("WhatsApp ack reaction", () => { - const mockSendReaction = vi.fn(async () => {}); - const mockGetReply = vi.fn(async () => ({ - payloads: [{ text: "test reply" }], - meta: {}, - })); - beforeEach(() => { vi.clearAllMocks(); }); @@ -265,7 +259,6 @@ describe("WhatsApp ack reaction", () => { }; const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); - const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const didSendReply = false; // No reply sent const shouldAckReaction = () => { @@ -296,7 +289,6 @@ describe("WhatsApp ack reaction", () => { }; const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); - const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const didSendReply = true; const shouldAckReaction = () => { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 08552ad13..a0c769bfe 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -1389,7 +1389,8 @@ export async function monitorWebProvider( // Send ack reaction after successful reply const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); - const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const ackReactionScope = + cfg.messages?.ackReactionScope ?? "group-mentions"; const shouldAckReaction = () => { if (!ackReaction) return false; if (!msg.id) return false; From 2daead27cf22d5f81a180b1d19a68c95ec4e403a Mon Sep 17 00:00:00 2001 From: sheeek Date: Sat, 10 Jan 2026 00:54:49 +0100 Subject: [PATCH 4/7] feat(whatsapp): redesign ack-reaction as whatsapp-specific feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move config from messages.ackReaction to whatsapp.ackReaction - New structure: {emoji, direct, group} with granular control - Support per-account overrides in whatsapp.accounts.*.ackReaction - Add Zod schema validation for new config - Maintain backward compatibility with old messages.ackReaction format - Update tests to new config structure (14 tests, all passing) - Add comprehensive documentation in docs/providers/whatsapp.md - Timing: reactions sent immediately upon message receipt (before bot reply) Breaking changes: - Config moved from messages.ackReaction to whatsapp.ackReaction - Scope values changed: 'all'/'direct'/'group-all'/'group-mentions' → direct: boolean + group: 'always'/'mentions'/'never' - Old config still supported via fallback for smooth migration --- docs/providers/whatsapp.md | 50 +++ src/config/types.ts | 30 ++ src/config/zod-schema.ts | 20 + src/web/auto-reply.ack-reaction.test.ts | 509 +++++++++++------------- src/web/auto-reply.ts | 131 ++++-- 5 files changed, 422 insertions(+), 318 deletions(-) diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md index e425adb17..67b415652 100644 --- a/docs/providers/whatsapp.md +++ b/docs/providers/whatsapp.md @@ -159,6 +159,54 @@ 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. + ## 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 +253,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/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/auto-reply.ack-reaction.test.ts b/src/web/auto-reply.ack-reaction.test.ts index 96471ad76..f34bd6735 100644 --- a/src/web/auto-reply.ack-reaction.test.ts +++ b/src/web/auto-reply.ack-reaction.test.ts @@ -1,303 +1,260 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { ClawdbotConfig } from "../config/types.js"; -describe("WhatsApp ack reaction", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); +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"; - it("should send ack reaction in direct chat when scope is 'all'", async () => { - const cfg: ClawdbotConfig = { - messages: { - ackReaction: "👀", - ackReactionScope: "all", - }, - }; + if (!emoji) return false; + if (!msg.id) return false; - // Simulate the logic from auto-reply.ts - const msg = { - id: "msg123", - chatId: "123456789@s.whatsapp.net", - chatType: "direct" as const, - from: "+1234567890", - to: "+9876543210", - body: "hello", - }; + // Direct chat logic + if (msg.chatType === "direct") { + return directEnabled; + } - const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); - const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; - const didSendReply = true; - - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (!msg.id) return false; - if (!didSendReply) return false; - if (ackReactionScope === "all") return true; - if (ackReactionScope === "direct") return msg.chatType === "direct"; - if (ackReactionScope === "group-all") return msg.chatType === "group"; - if (ackReactionScope === "group-mentions") { - if (msg.chatType !== "group") return false; - return false; // Would check wasMentioned - } - return false; - }; - - expect(shouldAckReaction()).toBe(true); - }); - - it("should send ack reaction in direct chat when scope is 'direct'", async () => { - const cfg: ClawdbotConfig = { - messages: { - ackReaction: "👀", - ackReactionScope: "direct", - }, - }; - - const msg = { - id: "msg123", - chatId: "123456789@s.whatsapp.net", - chatType: "direct" as const, - from: "+1234567890", - to: "+9876543210", - body: "hello", - }; - - const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); - const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; - const didSendReply = true; - - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (!msg.id) return false; - if (!didSendReply) return false; - if (ackReactionScope === "all") return true; - if (ackReactionScope === "direct") return msg.chatType === "direct"; - if (ackReactionScope === "group-all") return msg.chatType === "group"; - return false; - }; - - expect(shouldAckReaction()).toBe(true); - }); - - it("should NOT send ack reaction in group when scope is 'direct'", async () => { - const cfg: ClawdbotConfig = { - messages: { - ackReaction: "👀", - ackReactionScope: "direct", - }, - }; - - const msg = { - id: "msg123", - chatId: "123456789-group@g.us", - chatType: "group" as const, - from: "123456789-group@g.us", - to: "+9876543210", - body: "hello", - wasMentioned: true, - }; - - const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); - const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; - const didSendReply = true; - - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (!msg.id) return false; - if (!didSendReply) return false; - if (ackReactionScope === "all") return true; - if (ackReactionScope === "direct") return msg.chatType === "direct"; - if (ackReactionScope === "group-all") return msg.chatType === "group"; - return false; - }; - - expect(shouldAckReaction()).toBe(false); - }); - - it("should send ack reaction in group when mentioned and scope is 'group-mentions' (requireMention=true)", async () => { - const cfg: ClawdbotConfig = { - messages: { - ackReaction: "👀", - ackReactionScope: "group-mentions", - }, - }; - - const msg = { - id: "msg123", - chatId: "123456789-group@g.us", - chatType: "group" as const, - from: "123456789-group@g.us", - to: "+9876543210", - body: "hello @bot", - wasMentioned: true, - }; - - const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); - const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; - const didSendReply = true; - const requireMention = true; // Simulated from activation check - - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (!msg.id) return false; - if (!didSendReply) return false; - if (ackReactionScope === "all") return true; - if (ackReactionScope === "direct") return msg.chatType === "direct"; - if (ackReactionScope === "group-all") return msg.chatType === "group"; - if (ackReactionScope === "group-mentions") { - if (msg.chatType !== "group") return false; - // If mention is not required (activation === "always"), always react - if (!requireMention) return true; + // 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; - }; + } - expect(shouldAckReaction()).toBe(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 send ack reaction in group when requireMention=false and scope is 'group-mentions' (activation: always)", async () => { - const cfg: ClawdbotConfig = { - messages: { - ackReaction: "👀", - ackReactionScope: "group-mentions", - }, - }; + 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); + }); - const msg = { - id: "msg123", - chatId: "123456789-group@g.us", - chatType: "group" as const, - from: "123456789-group@g.us", - to: "+9876543210", - body: "hello", - wasMentioned: false, // No mention, but activation is "always" - }; - - const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); - const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; - const didSendReply = true; - const requireMention = false; // activation === "always" - - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (!msg.id) return false; - if (!didSendReply) return false; - if (ackReactionScope === "all") return true; - if (ackReactionScope === "direct") return msg.chatType === "direct"; - if (ackReactionScope === "group-all") return msg.chatType === "group"; - if (ackReactionScope === "group-mentions") { - if (msg.chatType !== "group") return false; - // If mention is not required (activation === "always"), always react - if (!requireMention) return true; - return msg.wasMentioned === true; - } - return false; - }; - - expect(shouldAckReaction()).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); + }); }); - it("should NOT send ack reaction in group when NOT mentioned and scope is 'group-mentions' (requireMention=true)", async () => { - const cfg: ClawdbotConfig = { - messages: { - ackReaction: "👀", - ackReactionScope: "group-mentions", - }, - }; + 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); + }); - const msg = { - id: "msg123", - chatId: "123456789-group@g.us", - chatType: "group" as const, - from: "123456789-group@g.us", - to: "+9876543210", - body: "hello", - wasMentioned: false, - }; + 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); + }); - const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); - const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; - const didSendReply = true; - const requireMention = true; - - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (!msg.id) return false; - if (!didSendReply) return false; - if (ackReactionScope === "all") return true; - if (ackReactionScope === "direct") return msg.chatType === "direct"; - if (ackReactionScope === "group-all") return msg.chatType === "group"; - if (ackReactionScope === "group-mentions") { - if (msg.chatType !== "group") return false; - // If mention is not required (activation === "always"), always react - if (!requireMention) return true; - return msg.wasMentioned === true; - } - return false; - }; - - expect(shouldAckReaction()).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); + }); }); - it("should NOT send ack reaction when no reply was sent", async () => { - const cfg: ClawdbotConfig = { - messages: { - ackReaction: "👀", - ackReactionScope: "all", - }, - }; + 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); + }); - const msg = { - id: "msg123", - chatId: "123456789@s.whatsapp.net", - chatType: "direct" as const, - from: "+1234567890", - to: "+9876543210", - body: "hello", - }; - - const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); - const didSendReply = false; // No reply sent - - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (!msg.id) return false; - if (!didSendReply) return false; - return true; - }; - - expect(shouldAckReaction()).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); + }); }); - it("should NOT send ack reaction when ackReaction is empty", async () => { - const cfg: ClawdbotConfig = { - messages: { - ackReaction: "", - ackReactionScope: "all", - }, - }; + describe("combinations", () => { + it("direct=false, group=always: only groups", () => { + const cfg: ClawdbotConfig = { + whatsapp: { + ackReaction: { emoji: "✅", direct: false, group: "always" }, + }, + }; - const msg = { - id: "msg123", - chatId: "123456789@s.whatsapp.net", - chatType: "direct" as const, - from: "+1234567890", - to: "+9876543210", - body: "hello", - }; + expect( + shouldSendReaction(cfg, { id: "m1", chatType: "direct" }), + ).toBe(false); - const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); - const didSendReply = true; + expect( + shouldSendReaction(cfg, { + id: "m2", + chatType: "group", + wasMentioned: false, + }), + ).toBe(true); + }); - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (!msg.id) return false; - if (!didSendReply) return false; - return true; - }; + it("direct=true, group=never: only direct", () => { + const cfg: ClawdbotConfig = { + whatsapp: { + ackReaction: { emoji: "🤖", direct: true, group: "never" }, + }, + }; - expect(shouldAckReaction()).toBe(false); + 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); + }); }); }); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index a0c769bfe..3f6a4df0a 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -1149,6 +1149,95 @@ export async function monitorWebProvider( status.lastMessageAt = Date.now(); status.lastEventAt = status.lastMessageAt; emitStatus(); + + // Send ack reaction immediately upon message receipt + if (msg.id) { + const ackConfig = cfg.whatsapp?.ackReaction; + // Backward compatibility: support old messages.ackReaction format + const legacyEmoji = (cfg.messages as any)?.ackReaction; + const legacyScope = (cfg.messages as any)?.ackReactionScope; + let emoji = (ackConfig?.emoji ?? "").trim(); + let directEnabled = ackConfig?.direct ?? true; + let groupMode = ackConfig?.group ?? "mentions"; + + // Fallback to legacy config if new config is not set + if (!emoji && typeof legacyEmoji === "string") { + emoji = legacyEmoji.trim(); + if (legacyScope === "all") { + directEnabled = true; + groupMode = "always"; + } else if (legacyScope === "direct") { + directEnabled = true; + groupMode = "never"; + } else if (legacyScope === "group-all") { + directEnabled = false; + groupMode = "always"; + } else if (legacyScope === "group-mentions") { + directEnabled = false; + groupMode = "mentions"; + } + } + + const conversationIdForCheck = msg.conversationId ?? msg.from; + + const shouldSendReaction = () => { + if (!emoji) 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") { + // Always react to group messages + return true; + } + if (groupMode === "mentions") { + // Check if group has requireMention setting + const activation = resolveGroupActivationFor({ + agentId: route.agentId, + sessionKey: route.sessionKey, + conversationId: conversationIdForCheck, + }); + // If group activation is "always" (requireMention=false), react to all + if (activation === "always") return true; + // Otherwise, only react if bot was mentioned + 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 conversationId = msg.conversationId ?? msg.from; let combinedBody = buildLine(msg, route.agentId); let shouldClearGroupHistory = false; @@ -1387,48 +1476,6 @@ export async function monitorWebProvider( groupHistories.set(groupHistoryKey, []); } - // Send ack reaction after successful reply - const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); - const ackReactionScope = - cfg.messages?.ackReactionScope ?? "group-mentions"; - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (!msg.id) return false; - if (!didSendReply) return false; - if (ackReactionScope === "all") return true; - if (ackReactionScope === "direct") return msg.chatType === "direct"; - if (ackReactionScope === "group-all") return msg.chatType === "group"; - if (ackReactionScope === "group-mentions") { - if (msg.chatType !== "group") return false; - const activation = resolveGroupActivationFor({ - agentId: route.agentId, - sessionKey: route.sessionKey, - conversationId, - }); - const requireMention = activation !== "always"; - // If mention is not required (activation === "always"), always react - if (!requireMention) return true; - // Otherwise, only react if bot was mentioned - return msg.wasMentioned === true; - } - return false; - }; - - if (shouldAckReaction() && msg.id) { - sendReactionWhatsApp(msg.chatId, msg.id, ackReaction, { - verbose, - fromMe: false, - }).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)}`, - ); - }); - } - return didSendReply; }; From 30b4c142967c50342cf3b3daa83e794845f36d8e Mon Sep 17 00:00:00 2001 From: sheeek Date: Sat, 10 Jan 2026 00:59:36 +0100 Subject: [PATCH 5/7] style: fix biome linting in ack-reaction tests --- src/web/auto-reply.ack-reaction.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/web/auto-reply.ack-reaction.test.ts b/src/web/auto-reply.ack-reaction.test.ts index f34bd6735..2d4094485 100644 --- a/src/web/auto-reply.ack-reaction.test.ts +++ b/src/web/auto-reply.ack-reaction.test.ts @@ -191,9 +191,9 @@ describe("WhatsApp ack reaction logic", () => { }, }; - expect( - shouldSendReaction(cfg, { id: "m1", chatType: "direct" }), - ).toBe(false); + expect(shouldSendReaction(cfg, { id: "m1", chatType: "direct" })).toBe( + false, + ); expect( shouldSendReaction(cfg, { @@ -211,9 +211,9 @@ describe("WhatsApp ack reaction logic", () => { }, }; - expect( - shouldSendReaction(cfg, { id: "m1", chatType: "direct" }), - ).toBe(true); + expect(shouldSendReaction(cfg, { id: "m1", chatType: "direct" })).toBe( + true, + ); expect( shouldSendReaction(cfg, { @@ -230,9 +230,9 @@ describe("WhatsApp ack reaction logic", () => { const cfg: ClawdbotConfig = { whatsapp: { ackReaction: { emoji: "👀" } }, }; - expect( - shouldSendReaction(cfg, { id: "m1", chatType: "direct" }), - ).toBe(true); + expect(shouldSendReaction(cfg, { id: "m1", chatType: "direct" })).toBe( + true, + ); }); it("should default group=mentions", () => { From c928df7237f682fcea2bc4fd1cad8296030df3b3 Mon Sep 17 00:00:00 2001 From: sheeek Date: Sat, 10 Jan 2026 01:04:13 +0100 Subject: [PATCH 6/7] fix: remove any casts in backward compat code --- src/web/auto-reply.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 3f6a4df0a..5cdac0f46 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -1153,9 +1153,15 @@ export async function monitorWebProvider( // Send ack reaction immediately upon message receipt if (msg.id) { const ackConfig = cfg.whatsapp?.ackReaction; - // Backward compatibility: support old messages.ackReaction format - const legacyEmoji = (cfg.messages as any)?.ackReaction; - const legacyScope = (cfg.messages as any)?.ackReactionScope; + // Backward compatibility: support old messages.ackReaction format (legacy, undocumented) + const messages = cfg.messages as + | undefined + | (typeof cfg.messages & { + ackReaction?: string; + ackReactionScope?: string; + }); + const legacyEmoji = messages?.ackReaction; + const legacyScope = messages?.ackReactionScope; let emoji = (ackConfig?.emoji ?? "").trim(); let directEnabled = ackConfig?.direct ?? true; let groupMode = ackConfig?.group ?? "mentions"; From 38604acd94a71177c05c2982d8489b854babbb74 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 11 Jan 2026 04:09:14 +0100 Subject: [PATCH 7/7] fix: tighten WhatsApp ack reactions and migrate config (#629) (thanks @pasogott) --- CHANGELOG.md | 1 + docs/providers/whatsapp.md | 1 + docs/tools/slash-commands.md | 6 +- src/commands/doctor-legacy-config.ts | 33 +++++ src/web/accounts.ts | 2 + src/web/auto-reply.ack-reaction.test.ts | 25 ++++ src/web/auto-reply.ts | 154 +++++++++--------------- 7 files changed, 125 insertions(+), 97 deletions(-) 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 67b415652..5adce09b8 100644 --- a/docs/providers/whatsapp.md +++ b/docs/providers/whatsapp.md @@ -206,6 +206,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately - 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`). 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/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 index 2d4094485..83dffe491 100644 --- a/src/web/auto-reply.ack-reaction.test.ts +++ b/src/web/auto-reply.ack-reaction.test.ts @@ -76,6 +76,17 @@ describe("WhatsApp ack reaction logic", () => { }), ).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", () => { @@ -257,4 +268,18 @@ describe("WhatsApp ack reaction logic", () => { ).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 5cdac0f46..6b3c4adc0 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -826,6 +826,7 @@ export async function monitorWebProvider( ...baseCfg, whatsapp: { ...baseCfg.whatsapp, + ackReaction: account.ackReaction, messagePrefix: account.messagePrefix, allowFrom: account.allowFrom, groupAllowFrom: account.groupAllowFrom, @@ -1149,101 +1150,6 @@ export async function monitorWebProvider( status.lastMessageAt = Date.now(); status.lastEventAt = status.lastMessageAt; emitStatus(); - - // Send ack reaction immediately upon message receipt - if (msg.id) { - const ackConfig = cfg.whatsapp?.ackReaction; - // Backward compatibility: support old messages.ackReaction format (legacy, undocumented) - const messages = cfg.messages as - | undefined - | (typeof cfg.messages & { - ackReaction?: string; - ackReactionScope?: string; - }); - const legacyEmoji = messages?.ackReaction; - const legacyScope = messages?.ackReactionScope; - let emoji = (ackConfig?.emoji ?? "").trim(); - let directEnabled = ackConfig?.direct ?? true; - let groupMode = ackConfig?.group ?? "mentions"; - - // Fallback to legacy config if new config is not set - if (!emoji && typeof legacyEmoji === "string") { - emoji = legacyEmoji.trim(); - if (legacyScope === "all") { - directEnabled = true; - groupMode = "always"; - } else if (legacyScope === "direct") { - directEnabled = true; - groupMode = "never"; - } else if (legacyScope === "group-all") { - directEnabled = false; - groupMode = "always"; - } else if (legacyScope === "group-mentions") { - directEnabled = false; - groupMode = "mentions"; - } - } - - const conversationIdForCheck = msg.conversationId ?? msg.from; - - const shouldSendReaction = () => { - if (!emoji) 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") { - // Always react to group messages - return true; - } - if (groupMode === "mentions") { - // Check if group has requireMention setting - const activation = resolveGroupActivationFor({ - agentId: route.agentId, - sessionKey: route.sessionKey, - conversationId: conversationIdForCheck, - }); - // If group activation is "always" (requireMention=false), react to all - if (activation === "always") return true; - // Otherwise, only react if bot was mentioned - 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 conversationId = msg.conversationId ?? msg.from; let combinedBody = buildLine(msg, route.agentId); let shouldClearGroupHistory = false; @@ -1294,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( {