fix(whatsapp): send group reactions with target participant (#65512)

This commit is contained in:
Marcus Castro
2026-04-12 20:00:19 -03:00
committed by GitHub
parent 82865ad480
commit 9af8288c05
6 changed files with 183 additions and 6 deletions

View File

@@ -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 } } },

View File

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

View File

@@ -16,12 +16,26 @@ vi.mock("./channel-react-action.runtime.js", async () => {
args: Record<string, unknown>;
toolContext?: { currentMessageId?: string | number | null };
}) => args.messageId ?? toolContext?.currentMessageId ?? null,
readStringOrNumberParam: (params: Record<string, unknown>, 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,
);
});

View File

@@ -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<string, unknown>;
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,
},

View File

@@ -136,7 +136,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
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<ResolvedWhatsAppAccount> =
params,
cfg,
accountId,
requesterSenderId,
toolContext,
}),
},

View File

@@ -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");