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(