From 34648235a3e64499c2e0aa2dabc5ba412fb682dd Mon Sep 17 00:00:00 2001 From: Marcus Castro <7562095+mcaxtr@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:42:19 -0300 Subject: [PATCH] WhatsApp: use shared resolveReactionMessageId for context-aware reactions (#57226) Wire the shared resolveReactionMessageId helper into the WhatsApp channel adapter, matching the pattern already used by Telegram, Signal, and Discord. The model can now react to the current inbound message without explicitly providing a messageId. Safety guards: - Only falls back to context when the source is WhatsApp - Suppresses fallback when targeting a different chat (normalized comparison) - Throws ToolInputError (400) instead of plain Error (500) when messageId is missing, preserving gateway error mapping --- CHANGELOG.md | 1 + extensions/whatsapp/src/channel.test.ts | 147 ++++++++++++++++++++++++ extensions/whatsapp/src/channel.ts | 33 +++++- 3 files changed, 178 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c36683d228..49a5467f89e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - LINE/outbound media: add LINE image, video, and audio outbound sends on the LINE-specific delivery path, including explicit preview/tracking handling for videos while keeping generic media sends on the existing image-only route. (#45826) Thanks @masatohoshino. +- WhatsApp/reactions: agents can now react with emoji on incoming WhatsApp messages, enabling more natural conversational interactions like acknowledging a photo with ❤️ instead of typing a reply. Thanks @mcaxtr. ### Fixes diff --git a/extensions/whatsapp/src/channel.test.ts b/extensions/whatsapp/src/channel.test.ts index 8bea552b46b..a5bd024b342 100644 --- a/extensions/whatsapp/src/channel.test.ts +++ b/extensions/whatsapp/src/channel.test.ts @@ -23,6 +23,7 @@ import type { OpenClawConfig } from "./runtime-api.js"; const hoisted = vi.hoisted(() => ({ sendPollWhatsApp: vi.fn(async () => ({ messageId: "wa-poll-1", toJid: "1555@s.whatsapp.net" })), + handleWhatsAppAction: vi.fn(async () => ({ content: [{ type: "text", text: '{"ok":true}' }] })), loginWeb: vi.fn(async () => {}), pathExists: vi.fn(async () => false), listWhatsAppAccountIds: vi.fn(() => [] as string[]), @@ -40,6 +41,7 @@ vi.mock("./runtime.js", () => ({ channel: { whatsapp: { sendPollWhatsApp: hoisted.sendPollWhatsApp, + handleWhatsAppAction: hoisted.handleWhatsAppAction, }, }, }), @@ -428,3 +430,148 @@ describe("whatsapp group policy", () => { }); }); }); + +describe("whatsappPlugin actions.handleAction react messageId resolution", () => { + const baseCfg = { + channels: { whatsapp: { actions: { reactions: true }, allowFrom: ["*"] } }, + } as OpenClawConfig; + + beforeEach(() => { + hoisted.handleWhatsAppAction.mockClear(); + }); + + it("uses explicit messageId when provided", async () => { + await whatsappPlugin.actions!.handleAction!({ + channel: "whatsapp", + action: "react", + params: { messageId: "explicit-id", emoji: "👍", to: "+1555" }, + cfg: baseCfg, + accountId: DEFAULT_ACCOUNT_ID, + }); + expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( + expect.objectContaining({ messageId: "explicit-id" }), + baseCfg, + ); + }); + + it("falls back to toolContext.currentMessageId when messageId omitted", async () => { + await whatsappPlugin.actions!.handleAction!({ + channel: "whatsapp", + action: "react", + params: { emoji: "❤️", to: "+1555" }, + cfg: baseCfg, + accountId: DEFAULT_ACCOUNT_ID, + toolContext: { + currentChannelId: "whatsapp:+1555", + currentChannelProvider: "whatsapp", + currentMessageId: "ctx-msg-42", + }, + }); + expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( + expect.objectContaining({ messageId: "ctx-msg-42" }), + baseCfg, + ); + }); + + it("converts numeric toolContext messageId to string", async () => { + await whatsappPlugin.actions!.handleAction!({ + channel: "whatsapp", + action: "react", + params: { emoji: "🎉", to: "+1555" }, + cfg: baseCfg, + accountId: DEFAULT_ACCOUNT_ID, + toolContext: { + currentChannelId: "whatsapp:+1555", + currentChannelProvider: "whatsapp", + currentMessageId: 12345, + }, + }); + expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( + expect.objectContaining({ messageId: "12345" }), + baseCfg, + ); + }); + + it("throws ToolInputError when messageId missing and no toolContext", async () => { + const err = await whatsappPlugin.actions!.handleAction!({ + channel: "whatsapp", + action: "react", + params: { emoji: "👍", to: "+1555" }, + cfg: baseCfg, + accountId: DEFAULT_ACCOUNT_ID, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).name).toBe("ToolInputError"); + }); + + it("skips context fallback when targeting a different chat", async () => { + const err = await whatsappPlugin.actions!.handleAction!({ + channel: "whatsapp", + action: "react", + params: { emoji: "👍", to: "+9999" }, + cfg: baseCfg, + accountId: DEFAULT_ACCOUNT_ID, + toolContext: { + currentChannelId: "whatsapp:+1555", + currentChannelProvider: "whatsapp", + currentMessageId: "ctx-msg-42", + }, + }).catch((e: unknown) => e); + // Different target chat → context fallback suppressed → ToolInputError + expect(err).toBeInstanceOf(Error); + expect((err as Error).name).toBe("ToolInputError"); + }); + + it("uses context fallback when target matches current chat (prefixed)", async () => { + await whatsappPlugin.actions!.handleAction!({ + channel: "whatsapp", + action: "react", + params: { emoji: "👍", to: "+1555" }, + cfg: baseCfg, + accountId: DEFAULT_ACCOUNT_ID, + toolContext: { + currentChannelId: "whatsapp:+1555", + currentChannelProvider: "whatsapp", + currentMessageId: "ctx-msg-42", + }, + }); + expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( + expect.objectContaining({ messageId: "ctx-msg-42" }), + baseCfg, + ); + }); + + it("skips context fallback when source is another provider", async () => { + const err = await whatsappPlugin.actions!.handleAction!({ + channel: "whatsapp", + action: "react", + params: { emoji: "👍", to: "+1555" }, + cfg: baseCfg, + accountId: DEFAULT_ACCOUNT_ID, + toolContext: { + currentChannelId: "telegram:-1003841603622", + currentChannelProvider: "telegram", + currentMessageId: "tg-msg-99", + }, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).name).toBe("ToolInputError"); + }); + + it("skips context fallback when currentChannelId is missing with explicit target", async () => { + const err = await whatsappPlugin.actions!.handleAction!({ + channel: "whatsapp", + action: "react", + params: { emoji: "👍", to: "+1555" }, + cfg: baseCfg, + accountId: DEFAULT_ACCOUNT_ID, + toolContext: { + currentChannelProvider: "whatsapp", + currentMessageId: "ctx-msg-42", + }, + }).catch((e: unknown) => e); + // WhatsApp source but no currentChannelId to compare → fallback suppressed + expect(err).toBeInstanceOf(Error); + expect((err as Error).name).toBe("ToolInputError"); + }); +}); diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index c54d9c6f6cd..ec75f272ad0 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,4 +1,5 @@ import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions"; import { createChatChannelPlugin } from "openclaw/plugin-sdk/core"; import { chunkText } from "openclaw/plugin-sdk/reply-runtime"; import { @@ -153,13 +154,39 @@ export const whatsappPlugin: ChannelPlugin = return { actions: Array.from(actions) }; }, supportsAction: ({ action }) => action === "react", - handleAction: async ({ action, params, cfg, accountId }) => { + handleAction: async ({ action, params, cfg, accountId, toolContext }) => { if (action !== "react") { throw new Error(`Action ${action} is not supported for provider ${WHATSAPP_CHANNEL}.`); } - const messageId = readStringParam(params, "messageId", { - required: true, + // Only fall back to the inbound message id when the current turn + // originates from WhatsApp and targets the same chat. Skip the + // fallback when the source is another provider (the message id + // would be meaningless) or when the caller routes to a different + // WhatsApp chat (the id would belong to the wrong conversation). + const isWhatsAppSource = toolContext?.currentChannelProvider === WHATSAPP_CHANNEL; + const explicitTarget = + readStringParam(params, "chatJid") ?? readStringParam(params, "to"); + const normalizedTarget = explicitTarget ? normalizeWhatsAppTarget(explicitTarget) : null; + const normalizedCurrent = + isWhatsAppSource && toolContext?.currentChannelId + ? normalizeWhatsAppTarget(toolContext.currentChannelId) + : null; + // When an explicit target is provided, require a known current chat + // to compare against. If currentChannelId is missing/unparseable, + // treat it as ineligible for fallback to avoid cross-chat leaks. + const isCrossChat = + normalizedTarget != null && + (normalizedCurrent == null || normalizedTarget !== normalizedCurrent); + const scopedContext = !isWhatsAppSource || isCrossChat ? undefined : toolContext; + const messageIdRaw = resolveReactionMessageId({ + args: params, + toolContext: scopedContext, }); + if (messageIdRaw == null) { + // Delegate to readStringParam so the gateway maps the error to 400. + readStringParam(params, "messageId", { required: true }); + } + const messageId = String(messageIdRaw); const emoji = readStringParam(params, "emoji", { allowEmpty: true }); const remove = typeof params.remove === "boolean" ? params.remove : undefined; return await getWhatsAppRuntime().channel.whatsapp.handleWhatsAppAction(