From 34d862d45d681bdeda5a0b9bccd37d2e65b4142d Mon Sep 17 00:00:00 2001 From: Marcus Castro <7562095+mcaxtr@users.noreply.github.com> Date: Mon, 25 May 2026 23:25:00 -0300 Subject: [PATCH] fix(whatsapp): restore ack emoji identity fallback (#86697) --- docs/channels/whatsapp.md | 2 + .../whatsapp/src/auto-reply.test-harness.ts | 7 ++ .../src/auto-reply/monitor/ack-emoji.test.ts | 109 ++++++++++++++++++ .../src/auto-reply/monitor/ack-emoji.ts | 27 +++++ .../auto-reply/monitor/ack-reaction.test.ts | 33 ++++++ .../src/auto-reply/monitor/ack-reaction.ts | 7 +- .../monitor/status-reaction.test.ts | 100 ++++++++++++++++ .../src/auto-reply/monitor/status-reaction.ts | 7 +- 8 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts create mode 100644 extensions/whatsapp/src/auto-reply/monitor/ack-emoji.ts create mode 100644 extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index dd74dd1b2a2..b16d9bade37 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -522,6 +522,7 @@ Ack reactions are gated by `reactionLevel` — they are suppressed when `reactio Behavior notes: - sent immediately after inbound is accepted (pre-reply) +- if `ackReaction` is present without `emoji`, WhatsApp uses the routed agent's identity emoji, falling back to "👀"; omit `ackReaction` or set `emoji: ""` to send no ack reaction - failures are logged but do not block normal reply delivery - group mode `mentions` reacts on mention-triggered turns; group activation `always` acts as bypass for this check - WhatsApp uses `channels.whatsapp.ackReaction` (legacy `messages.ackReaction` is not used here) @@ -548,6 +549,7 @@ Set `messages.statusReactions.enabled: true` to let WhatsApp replace the ack rea Behavior notes: - `channels.whatsapp.ackReaction` still controls whether status reactions are eligible for direct messages and groups. +- The queued status reaction uses the same effective ack emoji as plain ack reactions. - WhatsApp has one bot reaction slot per message, so lifecycle updates replace the current reaction in place. - `messages.removeAckAfterReply: true` clears the final status reaction after the configured done/error hold. - Tool emoji categories include `tool`, `coding`, `web`, `deploy`, `build`, and `concierge`. diff --git a/extensions/whatsapp/src/auto-reply.test-harness.ts b/extensions/whatsapp/src/auto-reply.test-harness.ts index b022d2a6c2d..5e4299fe60c 100644 --- a/extensions/whatsapp/src/auto-reply.test-harness.ts +++ b/extensions/whatsapp/src/auto-reply.test-harness.ts @@ -107,6 +107,13 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({ isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, + resolveAgentIdentity: ( + cfg: { agents?: { list?: Array<{ id: string; identity?: unknown }> } }, + agentId: string, + ) => + cfg.agents?.list?.find( + (entry) => entry.id.trim().toLowerCase() === agentId.trim().toLowerCase(), + )?.identity, resolveIdentityNamePrefix: (cfg: { messages?: { responsePrefix?: string } }, _agentId: string) => cfg.messages?.responsePrefix, resolveMessagePrefix: (cfg: { messages?: { messagePrefix?: string } }) => diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts new file mode 100644 index 00000000000..f314e798bb5 --- /dev/null +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts @@ -0,0 +1,109 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { describe, expect, it } from "vitest"; +import { resolveWhatsAppAckEmoji } from "./ack-emoji.js"; + +function createConfig( + ackReaction?: NonNullable< + NonNullable["whatsapp"]>["ackReaction"] + >, +): OpenClawConfig { + const cfg: OpenClawConfig = { + agents: { + list: [{ id: "agent", identity: { emoji: "🔥" } }], + }, + channels: { + whatsapp: {}, + }, + } as OpenClawConfig; + if (ackReaction !== undefined) { + cfg.channels!.whatsapp!.ackReaction = ackReaction; + } + return cfg; +} + +describe("resolveWhatsAppAckEmoji", () => { + it("keeps missing ackReaction config disabled", () => { + expect( + resolveWhatsAppAckEmoji({ + cfg: createConfig(), + agentId: "agent", + ackConfig: undefined, + }), + ).toBe(""); + }); + + it("uses the configured WhatsApp emoji when present", () => { + const cfg = createConfig({ emoji: " 👀 ", direct: true, group: "mentions" }); + + expect( + resolveWhatsAppAckEmoji({ + cfg, + agentId: "agent", + ackConfig: cfg.channels?.whatsapp?.ackReaction, + }), + ).toBe("👀"); + }); + + it("keeps an explicit empty emoji disabled", () => { + const cfg = createConfig({ emoji: " ", direct: true, group: "mentions" }); + + expect( + resolveWhatsAppAckEmoji({ + cfg, + agentId: "agent", + ackConfig: cfg.channels?.whatsapp?.ackReaction, + }), + ).toBe(""); + }); + + it("falls back to the routed agent identity emoji when the ack object has no emoji", () => { + const cfg = createConfig({ direct: true, group: "mentions" }); + + expect( + resolveWhatsAppAckEmoji({ + cfg, + agentId: "agent", + ackConfig: cfg.channels?.whatsapp?.ackReaction, + }), + ).toBe("🔥"); + }); + + it("uses normalized agent ids for the identity fallback", () => { + const cfg: OpenClawConfig = { + agents: { + list: [{ id: "Agent", identity: { emoji: "🔥" } }], + }, + channels: { + whatsapp: { + ackReaction: { direct: true, group: "mentions" }, + }, + }, + } as OpenClawConfig; + + expect( + resolveWhatsAppAckEmoji({ + cfg, + agentId: "agent", + ackConfig: cfg.channels?.whatsapp?.ackReaction, + }), + ).toBe("🔥"); + }); + + it("uses the default ack emoji when configured without an emoji or agent identity", () => { + const cfg: OpenClawConfig = { + channels: { + whatsapp: { + ackReaction: { direct: true, group: "mentions" }, + }, + }, + } as OpenClawConfig; + + expect( + resolveWhatsAppAckEmoji({ + cfg, + agentId: "agent", + ackConfig: cfg.channels?.whatsapp?.ackReaction, + }), + ).toBe("👀"); + }); +}); diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.ts new file mode 100644 index 00000000000..83a2d5f67b1 --- /dev/null +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.ts @@ -0,0 +1,27 @@ +import { resolveAgentIdentity } from "openclaw/plugin-sdk/agent-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; + +const DEFAULT_WHATSAPP_ACK_REACTION = "👀"; + +type WhatsAppAckReactionConfig = NonNullable< + NonNullable["whatsapp"]>["ackReaction"] +>; + +export function resolveWhatsAppAckEmoji(params: { + cfg: OpenClawConfig; + agentId: string; + ackConfig: WhatsAppAckReactionConfig | undefined; +}): string { + if (!params.ackConfig) { + return ""; + } + if (params.ackConfig.emoji !== undefined) { + return params.ackConfig.emoji.trim(); + } + return resolveAgentIdentityEmoji(params.cfg, params.agentId) ?? DEFAULT_WHATSAPP_ACK_REACTION; +} + +function resolveAgentIdentityEmoji(cfg: OpenClawConfig, agentId: string): string | undefined { + const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim(); + return emoji || undefined; +} diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts index d3a5fab49a9..c5f4baa5ad9 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts @@ -136,6 +136,39 @@ describe("maybeSendAckReaction", () => { expectAckReactionSent("work", cfg); }); + it("uses the agent identity emoji when WhatsApp ackReaction has no emoji", async () => { + const cfg = { + agents: { + list: [{ id: "agent", identity: { emoji: "🔥" } }], + }, + channels: { + whatsapp: { + reactionLevel: "ack", + ackReaction: { + direct: true, + group: "mentions", + }, + }, + }, + } as OpenClawConfig; + + const ackReaction = await runAckReaction({ cfg }); + + expect(ackReaction?.ackReactionValue).toBe("🔥"); + await expect(ackReaction?.ackReactionPromise).resolves.toBe(true); + expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith( + "15551234567@s.whatsapp.net", + "msg-1", + "🔥", + { + verbose: false, + fromMe: false, + accountId: "default", + cfg, + }, + ); + }); + it("returns a handle that removes the ack with an empty reaction", async () => { const cfg = createConfig("ack"); const ackReaction = await runAckReaction({ cfg }); diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts index 62f8feb2c99..fbd863d9332 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts @@ -10,6 +10,7 @@ import { resolveWhatsAppReactionLevel } from "../../reaction-level.js"; import { sendReactionWhatsApp } from "../../send.js"; import { formatError } from "../../session.js"; import type { WebInboundMsg } from "../types.js"; +import { resolveWhatsAppAckEmoji } from "./ack-emoji.js"; import { resolveGroupActivationFor } from "./group-activation.js"; export async function maybeSendAckReaction(params: { @@ -38,7 +39,11 @@ export async function maybeSendAckReaction(params: { } const ackConfig = params.cfg.channels?.whatsapp?.ackReaction; - const emoji = (ackConfig?.emoji ?? "").trim(); + const emoji = resolveWhatsAppAckEmoji({ + cfg: params.cfg, + agentId: params.agentId, + ackConfig, + }); const directEnabled = ackConfig?.direct ?? true; const groupMode = ackConfig?.group ?? "mentions"; const conversationIdForCheck = params.msg.conversationId ?? params.msg.from; diff --git a/extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts b/extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts new file mode 100644 index 00000000000..742c77126d8 --- /dev/null +++ b/extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts @@ -0,0 +1,100 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { WhatsAppSendResult } from "../../inbound/send-result.js"; +import type { WebInboundMessage } from "../../inbound/types.js"; +import { createWhatsAppStatusReactionController } from "./status-reaction.js"; + +const hoisted = vi.hoisted(() => ({ + sendReactionWhatsApp: vi.fn(async () => undefined), +})); + +vi.mock("../../send.js", () => ({ + sendReactionWhatsApp: hoisted.sendReactionWhatsApp, +})); + +function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult { + return { + kind, + messageId: id, + keys: [{ id }], + providerAccepted: true, + }; +} + +function createMessage(overrides: Partial = {}): WebInboundMessage { + return { + id: "msg-1", + from: "15551234567", + conversationId: "15551234567", + to: "15559876543", + accountId: "default", + body: "hello", + chatType: "direct", + chatId: "15551234567@s.whatsapp.net", + sendComposing: async () => {}, + reply: async () => acceptedSendResult("text", "r1"), + sendMedia: async () => acceptedSendResult("media", "m1"), + ...overrides, + }; +} + +describe("createWhatsAppStatusReactionController", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses the agent identity emoji when WhatsApp ackReaction has no emoji", async () => { + const cfg = { + agents: { + list: [{ id: "agent", identity: { emoji: "🔥" } }], + }, + messages: { + statusReactions: { + enabled: true, + timing: { + debounceMs: 1_000_000, + stallSoftMs: 1_000_000, + stallHardMs: 1_000_000, + doneHoldMs: 0, + errorHoldMs: 0, + }, + }, + }, + channels: { + whatsapp: { + reactionLevel: "ack", + ackReaction: { + direct: true, + group: "mentions", + }, + }, + }, + } as OpenClawConfig; + + const controller = await createWhatsAppStatusReactionController({ + cfg, + msg: createMessage(), + agentId: "agent", + sessionKey: "whatsapp:default:15551234567", + conversationId: "15551234567", + verbose: false, + accountId: "default", + }); + + void controller?.setQueued(); + await vi.waitFor(() => { + expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith( + "15551234567@s.whatsapp.net", + "msg-1", + "🔥", + { + verbose: false, + fromMe: false, + accountId: "default", + cfg, + }, + ); + }); + await controller?.clear(); + }); +}); diff --git a/extensions/whatsapp/src/auto-reply/monitor/status-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/status-reaction.ts index db2d7ded54b..9a130b5f322 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/status-reaction.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/status-reaction.ts @@ -9,6 +9,7 @@ import { getSenderIdentity } from "../../identity.js"; import { resolveWhatsAppReactionLevel } from "../../reaction-level.js"; import { sendReactionWhatsApp } from "../../send.js"; import type { WebInboundMsg } from "../types.js"; +import { resolveWhatsAppAckEmoji } from "./ack-emoji.js"; import { resolveGroupActivationFor } from "./group-activation.js"; export type { StatusReactionController }; @@ -44,7 +45,11 @@ export async function createWhatsAppStatusReactionController( } const ackConfig = params.cfg.channels?.whatsapp?.ackReaction; - const ackEmoji = (ackConfig?.emoji ?? "").trim(); + const ackEmoji = resolveWhatsAppAckEmoji({ + cfg: params.cfg, + agentId: params.agentId, + ackConfig, + }); if (!ackEmoji) { return null; }