From 38604acd94a71177c05c2982d8489b854babbb74 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 11 Jan 2026 04:09:14 +0100 Subject: [PATCH] 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( {