diff --git a/extensions/whatsapp/src/channel.message-action-targets.test.ts b/extensions/whatsapp/src/channel.message-action-targets.test.ts new file mode 100644 index 000000000000..8b6893fa0fbf --- /dev/null +++ b/extensions/whatsapp/src/channel.message-action-targets.test.ts @@ -0,0 +1,21 @@ +// Whatsapp tests cover provider-owned message action target declarations. +import { describe, expect, it } from "vitest"; +import { whatsappPlugin } from "./channel.js"; + +describe("WhatsApp message action targets", () => { + it("declares chatJid as reaction delivery authority", () => { + const aliasSpec = whatsappPlugin.actions?.messageActionTargetAliases?.react; + + expect(aliasSpec?.aliases).toEqual(["chatJid", "messageId"]); + expect(aliasSpec?.deliveryTargetAliases).toEqual(["chatJid"]); + expect( + aliasSpec?.resolveDeliveryTarget?.({ args: { chatJid: "15551234567@s.whatsapp.net" } }), + ).toBe("+15551234567"); + expect( + aliasSpec?.resolveDeliveryTarget?.({ args: { chatJid: "whatsapp:12345-67890@g.us" } }), + ).toBe("12345-67890@g.us"); + expect( + aliasSpec?.resolveDeliveryTarget?.({ args: { chatJid: "invalid@jid" } }), + ).toBeUndefined(); + }); +}); diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index a7d6b1587a46..ecaaeeb5d573 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -61,6 +61,11 @@ function resolveWhatsAppTargetInfo(raw: string) { }; } +function resolveWhatsAppMessageActionTarget(params: { args: Record }) { + const chatJid = params.args.chatJid; + return typeof chatJid === "string" ? normalizeWhatsAppMessagingTarget(chatJid) : undefined; +} + export const whatsappPlugin: ChannelPlugin = createChatChannelPlugin({ pairing: { @@ -155,6 +160,13 @@ export const whatsappPlugin: ChannelPlugin = (await loadWhatsAppDirectoryConfig()).listWhatsAppDirectoryGroupsLive(params), }, actions: { + messageActionTargetAliases: { + react: { + aliases: ["chatJid", "messageId"], + deliveryTargetAliases: ["chatJid"], + resolveDeliveryTarget: resolveWhatsAppMessageActionTarget, + }, + }, describeMessageTool: ({ cfg, accountId }) => describeWhatsAppMessageActions({ cfg, accountId }), supportsAction: ({ action }) => action === "react" || action === "upload-file", diff --git a/extensions/whatsapp/src/outbound-base.test.ts b/extensions/whatsapp/src/outbound-base.test.ts index 822d2e7e99b5..1d49ff2a34bd 100644 --- a/extensions/whatsapp/src/outbound-base.test.ts +++ b/extensions/whatsapp/src/outbound-base.test.ts @@ -465,6 +465,79 @@ describe("createWhatsAppOutboundBase", () => { }); }); + it("keeps concurrent same-id replies isolated by target and account", async () => { + const replyToId = "reply-concurrent"; + cacheInboundMessageMeta("account-a", "11111@s.whatsapp.net", replyToId, { + participant: "11111@s.whatsapp.net", + body: "account a body", + }); + cacheInboundMessageMeta("account-b", "22222@s.whatsapp.net", replyToId, { + participant: "22222@s.whatsapp.net", + body: "account b body", + }); + const sendMessageWhatsApp = vi.fn< + Parameters[0]["sendMessageWhatsApp"] + >(async (to) => ({ + messageId: `sent-${to}`, + toJid: to, + })); + const outbound = createWhatsAppOutboundBase({ + chunker: (text) => [text], + sendMessageWhatsApp, + sendPollWhatsApp: vi.fn(), + shouldLogVerbose: () => false, + resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }), + }); + + await Promise.all([ + outbound.sendText!({ + cfg: {} as never, + to: "whatsapp:+11111", + text: "reply a", + accountId: "account-a", + deps: { sendWhatsApp: sendMessageWhatsApp }, + replyToId, + }), + outbound.sendText!({ + cfg: {} as never, + to: "whatsapp:+22222", + text: "reply b", + accountId: "account-b", + deps: { sendWhatsApp: sendMessageWhatsApp }, + replyToId, + }), + ]); + + const calls = sendMessageWhatsApp.mock.calls.map((call) => ({ + text: call[1], + quotedMessageKey: call[2].quotedMessageKey, + })); + expect(calls).toEqual( + expect.arrayContaining([ + { + text: "reply a", + quotedMessageKey: { + id: replyToId, + remoteJid: "11111@s.whatsapp.net", + fromMe: false, + participant: "11111@s.whatsapp.net", + messageText: "account a body", + }, + }, + { + text: "reply b", + quotedMessageKey: { + id: replyToId, + remoteJid: "22222@s.whatsapp.net", + fromMe: false, + participant: "22222@s.whatsapp.net", + messageText: "account b body", + }, + }, + ]), + ); + }); + it("normalizes mediaUrls before payload delivery", async () => { const sendMessageWhatsApp = vi.fn(async () => ({ messageId: "msg-1", diff --git a/extensions/whatsapp/src/quoted-message.test.ts b/extensions/whatsapp/src/quoted-message.test.ts index 6f799600f24b..b1dc2a8faed1 100644 --- a/extensions/whatsapp/src/quoted-message.test.ts +++ b/extensions/whatsapp/src/quoted-message.test.ts @@ -133,4 +133,47 @@ describe("quoted message metadata cache", () => { lookupInboundMessageMetaForTarget("account-d", "222@s.whatsapp.net", "msg-3"), ).toBeUndefined(); }); + + it.each(["account-a-first", "account-b-first"] as const)( + "keeps same-id cache entries isolated with $s insertion order", + (order) => { + const messageId = `shared-${order}`; + const entries = [ + { + accountId: "isolation-a", + remoteJid: "11111@s.whatsapp.net", + participant: "11111@s.whatsapp.net", + body: "account a body", + }, + { + accountId: "isolation-b", + remoteJid: "22222@s.whatsapp.net", + participant: "22222@s.whatsapp.net", + body: "account b body", + }, + ]; + if (order === "account-b-first") { + entries.reverse(); + } + for (const entry of entries) { + cacheInboundMessageMeta(entry.accountId, entry.remoteJid, messageId, { + participant: entry.participant, + body: entry.body, + }); + } + + expect( + lookupInboundMessageMetaForTarget("isolation-a", "11111@s.whatsapp.net", messageId)?.body, + ).toBe("account a body"); + expect( + lookupInboundMessageMetaForTarget("isolation-b", "22222@s.whatsapp.net", messageId)?.body, + ).toBe("account b body"); + expect( + lookupInboundMessageMetaForTarget("isolation-a", "22222@s.whatsapp.net", messageId), + ).toBeUndefined(); + expect( + lookupInboundMessageMetaForTarget("isolation-b", "11111@s.whatsapp.net", messageId), + ).toBeUndefined(); + }, + ); }); diff --git a/src/channels/plugins/message-actions.security.test.ts b/src/channels/plugins/message-actions.security.test.ts index d0ea2e463406..43a87b3c325c 100644 --- a/src/channels/plugins/message-actions.security.test.ts +++ b/src/channels/plugins/message-actions.security.test.ts @@ -825,6 +825,123 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => { expect(handleAction).toHaveBeenCalledOnce(); }); + it("accepts an exact-current WhatsApp chatJid delivery alias", async () => { + setReadPlugin({ + channel: "whatsapp", + origin: "bundled", + normalizeTarget: (raw) => raw.replace(/^whatsapp:/i, "").trim() || undefined, + messageActionTargetAliases: { + react: { + aliases: ["chatJid", "messageId"], + deliveryTargetAliases: ["chatJid"], + resolveDeliveryTarget: ({ args }) => + typeof args.chatJid === "string" ? args.chatJid : undefined, + }, + }, + }); + + await dispatchChannelMessageAction({ + channel: "whatsapp", + action: "react", + cfg: {} as OpenClawConfig, + params: { + chatJid: "current@g.us", + messageId: "current-message", + }, + accountId: "Work", + requesterAccountId: "work", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "whatsapp", + currentChannelId: "whatsapp:current@g.us", + currentMessageId: "current-message", + }, + }); + + expect(handleAction).toHaveBeenCalledOnce(); + }); + + it("rejects a sibling WhatsApp chatJid delivery alias before plugin code", async () => { + setReadPlugin({ + channel: "whatsapp", + origin: "bundled", + normalizeTarget: (raw) => raw.replace(/^whatsapp:/i, "").trim() || undefined, + messageActionTargetAliases: { + react: { + aliases: ["chatJid", "messageId"], + deliveryTargetAliases: ["chatJid"], + resolveDeliveryTarget: ({ args }) => + typeof args.chatJid === "string" ? args.chatJid : undefined, + }, + }, + }); + + await expect( + dispatchChannelMessageAction({ + channel: "whatsapp", + action: "react", + cfg: {} as OpenClawConfig, + params: { + chatJid: "sibling@g.us", + messageId: "sibling-message", + }, + accountId: "work", + requesterAccountId: "Work", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "whatsapp", + currentChannelId: "whatsapp:current@g.us", + currentMessageId: "current-message", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it.each([ + { name: "mismatched", accountId: "other", requesterAccountId: "work" }, + { name: "invalid", accountId: "!!!", requesterAccountId: "work" }, + { name: "missing requester", accountId: "work", requesterAccountId: undefined }, + ])( + "rejects a WhatsApp chatJid with $name account context before resolution", + async (testCase) => { + const resolveDeliveryTarget = vi.fn(({ args }: { args: Record }) => + typeof args.chatJid === "string" ? args.chatJid : undefined, + ); + setReadPlugin({ + channel: "whatsapp", + origin: "bundled", + normalizeTarget: (raw) => raw.replace(/^whatsapp:/i, "").trim() || undefined, + messageActionTargetAliases: { + react: { + aliases: ["chatJid", "messageId"], + deliveryTargetAliases: ["chatJid"], + resolveDeliveryTarget, + }, + }, + }); + + await expect( + dispatchChannelMessageAction({ + channel: "whatsapp", + action: "react", + cfg: {} as OpenClawConfig, + params: { chatJid: "current@g.us", messageId: "current-message" }, + accountId: testCase.accountId, + requesterAccountId: testCase.requesterAccountId, + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "whatsapp", + currentChannelId: "whatsapp:current@g.us", + currentMessageId: "current-message", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(resolveDeliveryTarget).not.toHaveBeenCalled(); + expect(handleAction).not.toHaveBeenCalled(); + }, + ); + it("uses a bundled numeric chatId delivery alias for an exact-current provider target", async () => { const resolveDeliveryTarget = vi.fn(({ args }: { args: Record }) => typeof args.chatId === "number" && Number.isInteger(args.chatId) && args.chatId > 0