mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 12:21:36 +00:00
fix(whatsapp): bind reactions to resolved chat target
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user