fix(whatsapp): bind reactions to resolved chat target

This commit is contained in:
joshavant
2026-07-23 16:49:08 -05:00
committed by Josh Avant
parent be29b907f4
commit 60090fc2a6
5 changed files with 266 additions and 0 deletions

View File

@@ -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();
});
});

View File

@@ -61,6 +61,11 @@ function resolveWhatsAppTargetInfo(raw: string) {
};
}
function resolveWhatsAppMessageActionTarget(params: { args: Record<string, unknown> }) {
const chatJid = params.args.chatJid;
return typeof chatJid === "string" ? normalizeWhatsAppMessagingTarget(chatJid) : undefined;
}
export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
createChatChannelPlugin<ResolvedWhatsAppAccount>({
pairing: {
@@ -155,6 +160,13 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
(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",

View File

@@ -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<typeof createWhatsAppOutboundBase>[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",

View File

@@ -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();
},
);
});

View File

@@ -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<string, unknown> }) =>
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<string, unknown> }) =>
typeof args.chatId === "number" && Number.isInteger(args.chatId) && args.chatId > 0