diff --git a/extensions/whatsapp/src/action-runtime.test.ts b/extensions/whatsapp/src/action-runtime.test.ts index 69fc7541884..4e5dc9d5ec8 100644 --- a/extensions/whatsapp/src/action-runtime.test.ts +++ b/extensions/whatsapp/src/action-runtime.test.ts @@ -136,6 +136,25 @@ describe("handleWhatsAppAction", () => { }); }); + it("preserves LID participant ids when forwarding reactions", async () => { + await handleWhatsAppAction( + { + action: "react", + chatJid: "12345@g.us", + messageId: "msg1", + emoji: "🎉", + participant: "123@lid", + }, + enabledConfig, + ); + expect(sendReactionWhatsApp).toHaveBeenLastCalledWith("12345@g.us", "msg1", "🎉", { + verbose: false, + fromMe: undefined, + participant: "123@lid", + accountId: DEFAULT_ACCOUNT_ID, + }); + }); + it("respects reaction gating", async () => { const cfg = { channels: { whatsapp: { actions: { reactions: false } } }, diff --git a/extensions/whatsapp/src/channel-react-action.runtime.ts b/extensions/whatsapp/src/channel-react-action.runtime.ts index dab8bffaff4..94c159feed1 100644 --- a/extensions/whatsapp/src/channel-react-action.runtime.ts +++ b/extensions/whatsapp/src/channel-react-action.runtime.ts @@ -1,7 +1,7 @@ -import { readStringParam } from "openclaw/plugin-sdk/channel-actions"; +import { readStringOrNumberParam, readStringParam } from "openclaw/plugin-sdk/channel-actions"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; export { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions"; export { handleWhatsAppAction } from "./action-runtime.js"; -export { normalizeWhatsAppTarget } from "./normalize.js"; -export { readStringParam, type OpenClawConfig }; +export { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize.js"; +export { readStringOrNumberParam, readStringParam, type OpenClawConfig }; diff --git a/extensions/whatsapp/src/channel-react-action.test.ts b/extensions/whatsapp/src/channel-react-action.test.ts index 05623b96898..93bbfcdf344 100644 --- a/extensions/whatsapp/src/channel-react-action.test.ts +++ b/extensions/whatsapp/src/channel-react-action.test.ts @@ -16,12 +16,26 @@ vi.mock("./channel-react-action.runtime.js", async () => { args: Record; toolContext?: { currentMessageId?: string | number | null }; }) => args.messageId ?? toolContext?.currentMessageId ?? null, + readStringOrNumberParam: (params: Record, key: string) => { + const value = params[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + return value; + } + return undefined; + }, + isWhatsAppGroupJid: (value?: string | null) => (value ?? "").trim().endsWith("@g.us"), normalizeWhatsAppTarget: (value?: string | null) => { const raw = (value ?? "").trim(); if (!raw) { return null; } const stripped = raw.replace(/^whatsapp:/, ""); + if (stripped.endsWith("@g.us")) { + return stripped; + } return stripped.startsWith("+") ? stripped : `+${stripped.replace(/^\+/, "")}`; }, readStringParam: ( @@ -138,11 +152,34 @@ describe("whatsapp react action messageId resolution", () => { }); it("uses context fallback when target matches current chat", async () => { + await handleWhatsAppReactAction({ + action: "react", + params: { emoji: "👍", to: "12345@g.us" }, + cfg: baseCfg, + accountId: "default", + requesterSenderId: "123@lid", + toolContext: { + currentChannelId: "whatsapp:12345@g.us", + currentChannelProvider: "whatsapp", + currentMessageId: "ctx-msg-42", + }, + }); + expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: "ctx-msg-42", + participant: "123@lid", + }), + baseCfg, + ); + }); + + it("keeps direct-chat reactions without an inferred participant", async () => { await handleWhatsAppReactAction({ action: "react", params: { emoji: "👍", to: "+1555" }, cfg: baseCfg, accountId: "default", + requesterSenderId: "123@lid", toolContext: { currentChannelId: "whatsapp:+1555", currentChannelProvider: "whatsapp", @@ -150,7 +187,76 @@ describe("whatsapp react action messageId resolution", () => { }, }); expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( - expect.objectContaining({ messageId: "ctx-msg-42" }), + expect.objectContaining({ + messageId: "ctx-msg-42", + participant: undefined, + }), + baseCfg, + ); + }); + + it("prefers explicit participant over inferred current-message participant", async () => { + await handleWhatsAppReactAction({ + action: "react", + params: { + emoji: "👍", + to: "12345@g.us", + participant: "555@s.whatsapp.net", + }, + cfg: baseCfg, + accountId: "default", + requesterSenderId: "123@lid", + toolContext: { + currentChannelId: "whatsapp:12345@g.us", + currentChannelProvider: "whatsapp", + currentMessageId: "ctx-msg-42", + }, + }); + expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: "ctx-msg-42", + participant: "555@s.whatsapp.net", + }), + baseCfg, + ); + }); + + it("does not reuse the current-chat participant for cross-chat reactions", async () => { + const err = await handleWhatsAppReactAction({ + action: "react", + params: { emoji: "👍", to: "99999@g.us" }, + cfg: baseCfg, + accountId: "default", + requesterSenderId: "123@lid", + toolContext: { + currentChannelId: "whatsapp:12345@g.us", + currentChannelProvider: "whatsapp", + currentMessageId: "ctx-msg-42", + }, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).name).toBe("ToolInputError"); + expect(hoisted.handleWhatsAppAction).not.toHaveBeenCalled(); + }); + + it("does not infer participant when messageId is explicitly provided", async () => { + await handleWhatsAppReactAction({ + action: "react", + params: { emoji: "👍", to: "12345@g.us", messageId: "older-msg-7" }, + cfg: baseCfg, + accountId: "default", + requesterSenderId: "123@lid", + toolContext: { + currentChannelId: "whatsapp:12345@g.us", + currentChannelProvider: "whatsapp", + currentMessageId: "ctx-msg-42", + }, + }); + expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: "older-msg-7", + participant: undefined, + }), baseCfg, ); }); diff --git a/extensions/whatsapp/src/channel-react-action.ts b/extensions/whatsapp/src/channel-react-action.ts index 81bad7038f5..cb113afb4d4 100644 --- a/extensions/whatsapp/src/channel-react-action.ts +++ b/extensions/whatsapp/src/channel-react-action.ts @@ -1,7 +1,9 @@ import { + isWhatsAppGroupJid, resolveReactionMessageId, handleWhatsAppAction, normalizeWhatsAppTarget, + readStringOrNumberParam, readStringParam, type OpenClawConfig, } from "./channel-react-action.runtime.js"; @@ -13,6 +15,7 @@ export async function handleWhatsAppReactAction(params: { params: Record; cfg: OpenClawConfig; accountId?: string | null; + requesterSenderId?: string | null; toolContext?: { currentChannelId?: string | null; currentChannelProvider?: string | null; @@ -49,8 +52,20 @@ export async function handleWhatsAppReactAction(params: { readStringParam(params.params, "messageId", { required: true }); } const messageId = String(messageIdRaw); + const explicitMessageId = readStringOrNumberParam(params.params, "messageId"); const emoji = readStringParam(params.params, "emoji", { allowEmpty: true }); const remove = typeof params.params.remove === "boolean" ? params.params.remove : undefined; + const explicitParticipant = readStringParam(params.params, "participant"); + const inferredParticipant = + explicitParticipant || + explicitMessageId != null || + !isWhatsAppSource || + isCrossChat || + !isWhatsAppGroupJid(explicitTarget ?? params.toolContext?.currentChannelId ?? "") + ? undefined + : typeof params.requesterSenderId === "string" && params.requesterSenderId.trim().length > 0 + ? params.requesterSenderId.trim() + : undefined; return await handleWhatsAppAction( { action: "react", @@ -60,7 +75,7 @@ export async function handleWhatsAppReactAction(params: { messageId, emoji, remove, - participant: readStringParam(params.params, "participant"), + participant: explicitParticipant ?? inferredParticipant, accountId: params.accountId ?? undefined, fromMe: typeof params.params.fromMe === "boolean" ? params.params.fromMe : undefined, }, diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 8cd9adcfdd9..664ed246180 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -136,7 +136,7 @@ export const whatsappPlugin: ChannelPlugin = describeWhatsAppMessageActions({ cfg, accountId }), supportsAction: ({ action }) => action === "react", resolveExecutionMode: ({ action }) => (action === "react" ? "gateway" : "local"), - handleAction: async ({ action, params, cfg, accountId, toolContext }) => + handleAction: async ({ action, params, cfg, accountId, requesterSenderId, toolContext }) => await ( await loadWhatsAppChannelReactAction() ).handleWhatsAppReactAction({ @@ -144,6 +144,7 @@ export const whatsappPlugin: ChannelPlugin = params, cfg, accountId, + requesterSenderId, toolContext, }), }, diff --git a/extensions/whatsapp/src/inbound/send-api.test.ts b/extensions/whatsapp/src/inbound/send-api.test.ts index 89cff3fd96a..e1919151990 100644 --- a/extensions/whatsapp/src/inbound/send-api.test.ts +++ b/extensions/whatsapp/src/inbound/send-api.test.ts @@ -158,6 +158,42 @@ describe("createWebSendApi", () => { ); }); + it("keeps direct-chat reactions without a participant key", async () => { + await api.sendReaction("+1555", "msg-2", "👍", false); + expect(sendMessage).toHaveBeenCalledWith( + "1555@s.whatsapp.net", + expect.objectContaining({ + react: { + text: "👍", + key: expect.objectContaining({ + remoteJid: "1555@s.whatsapp.net", + id: "msg-2", + fromMe: false, + participant: undefined, + }), + }, + }), + ); + }); + + it("preserves LID participants in reaction keys", async () => { + await api.sendReaction("12345@g.us", "msg-2", "👍", false, "123@lid"); + expect(sendMessage).toHaveBeenCalledWith( + "12345@g.us", + expect.objectContaining({ + react: { + text: "👍", + key: expect.objectContaining({ + remoteJid: "12345@g.us", + id: "msg-2", + fromMe: false, + participant: "123@lid", + }), + }, + }), + ); + }); + it("sends composing presence updates to the recipient JID", async () => { await api.sendComposingTo("+1555"); expect(sendPresenceUpdate).toHaveBeenCalledWith("composing", "1555@s.whatsapp.net");