mirror of https://github.com/openclaw/openclaw.git
WhatsApp: use shared resolveReactionMessageId for context-aware reactions (#57226)
Wire the shared resolveReactionMessageId helper into the WhatsApp channel adapter, matching the pattern already used by Telegram, Signal, and Discord. The model can now react to the current inbound message without explicitly providing a messageId. Safety guards: - Only falls back to context when the source is WhatsApp - Suppresses fallback when targeting a different chat (normalized comparison) - Throws ToolInputError (400) instead of plain Error (500) when messageId is missing, preserving gateway error mapping
This commit is contained in:
parent
f38b7291f9
commit
34648235a3
|
|
@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
|
|||
### Changes
|
||||
|
||||
- LINE/outbound media: add LINE image, video, and audio outbound sends on the LINE-specific delivery path, including explicit preview/tracking handling for videos while keeping generic media sends on the existing image-only route. (#45826) Thanks @masatohoshino.
|
||||
- WhatsApp/reactions: agents can now react with emoji on incoming WhatsApp messages, enabling more natural conversational interactions like acknowledging a photo with ❤️ instead of typing a reply. Thanks @mcaxtr.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import type { OpenClawConfig } from "./runtime-api.js";
|
|||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
sendPollWhatsApp: vi.fn(async () => ({ messageId: "wa-poll-1", toJid: "1555@s.whatsapp.net" })),
|
||||
handleWhatsAppAction: vi.fn(async () => ({ content: [{ type: "text", text: '{"ok":true}' }] })),
|
||||
loginWeb: vi.fn(async () => {}),
|
||||
pathExists: vi.fn(async () => false),
|
||||
listWhatsAppAccountIds: vi.fn(() => [] as string[]),
|
||||
|
|
@ -40,6 +41,7 @@ vi.mock("./runtime.js", () => ({
|
|||
channel: {
|
||||
whatsapp: {
|
||||
sendPollWhatsApp: hoisted.sendPollWhatsApp,
|
||||
handleWhatsAppAction: hoisted.handleWhatsAppAction,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
@ -428,3 +430,148 @@ describe("whatsapp group policy", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("whatsappPlugin actions.handleAction react messageId resolution", () => {
|
||||
const baseCfg = {
|
||||
channels: { whatsapp: { actions: { reactions: true }, allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.handleWhatsAppAction.mockClear();
|
||||
});
|
||||
|
||||
it("uses explicit messageId when provided", async () => {
|
||||
await whatsappPlugin.actions!.handleAction!({
|
||||
channel: "whatsapp",
|
||||
action: "react",
|
||||
params: { messageId: "explicit-id", emoji: "👍", to: "+1555" },
|
||||
cfg: baseCfg,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ messageId: "explicit-id" }),
|
||||
baseCfg,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to toolContext.currentMessageId when messageId omitted", async () => {
|
||||
await whatsappPlugin.actions!.handleAction!({
|
||||
channel: "whatsapp",
|
||||
action: "react",
|
||||
params: { emoji: "❤️", to: "+1555" },
|
||||
cfg: baseCfg,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
toolContext: {
|
||||
currentChannelId: "whatsapp:+1555",
|
||||
currentChannelProvider: "whatsapp",
|
||||
currentMessageId: "ctx-msg-42",
|
||||
},
|
||||
});
|
||||
expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ messageId: "ctx-msg-42" }),
|
||||
baseCfg,
|
||||
);
|
||||
});
|
||||
|
||||
it("converts numeric toolContext messageId to string", async () => {
|
||||
await whatsappPlugin.actions!.handleAction!({
|
||||
channel: "whatsapp",
|
||||
action: "react",
|
||||
params: { emoji: "🎉", to: "+1555" },
|
||||
cfg: baseCfg,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
toolContext: {
|
||||
currentChannelId: "whatsapp:+1555",
|
||||
currentChannelProvider: "whatsapp",
|
||||
currentMessageId: 12345,
|
||||
},
|
||||
});
|
||||
expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ messageId: "12345" }),
|
||||
baseCfg,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws ToolInputError when messageId missing and no toolContext", async () => {
|
||||
const err = await whatsappPlugin.actions!.handleAction!({
|
||||
channel: "whatsapp",
|
||||
action: "react",
|
||||
params: { emoji: "👍", to: "+1555" },
|
||||
cfg: baseCfg,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
}).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).name).toBe("ToolInputError");
|
||||
});
|
||||
|
||||
it("skips context fallback when targeting a different chat", async () => {
|
||||
const err = await whatsappPlugin.actions!.handleAction!({
|
||||
channel: "whatsapp",
|
||||
action: "react",
|
||||
params: { emoji: "👍", to: "+9999" },
|
||||
cfg: baseCfg,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
toolContext: {
|
||||
currentChannelId: "whatsapp:+1555",
|
||||
currentChannelProvider: "whatsapp",
|
||||
currentMessageId: "ctx-msg-42",
|
||||
},
|
||||
}).catch((e: unknown) => e);
|
||||
// Different target chat → context fallback suppressed → ToolInputError
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).name).toBe("ToolInputError");
|
||||
});
|
||||
|
||||
it("uses context fallback when target matches current chat (prefixed)", async () => {
|
||||
await whatsappPlugin.actions!.handleAction!({
|
||||
channel: "whatsapp",
|
||||
action: "react",
|
||||
params: { emoji: "👍", to: "+1555" },
|
||||
cfg: baseCfg,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
toolContext: {
|
||||
currentChannelId: "whatsapp:+1555",
|
||||
currentChannelProvider: "whatsapp",
|
||||
currentMessageId: "ctx-msg-42",
|
||||
},
|
||||
});
|
||||
expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ messageId: "ctx-msg-42" }),
|
||||
baseCfg,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips context fallback when source is another provider", async () => {
|
||||
const err = await whatsappPlugin.actions!.handleAction!({
|
||||
channel: "whatsapp",
|
||||
action: "react",
|
||||
params: { emoji: "👍", to: "+1555" },
|
||||
cfg: baseCfg,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
toolContext: {
|
||||
currentChannelId: "telegram:-1003841603622",
|
||||
currentChannelProvider: "telegram",
|
||||
currentMessageId: "tg-msg-99",
|
||||
},
|
||||
}).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).name).toBe("ToolInputError");
|
||||
});
|
||||
|
||||
it("skips context fallback when currentChannelId is missing with explicit target", async () => {
|
||||
const err = await whatsappPlugin.actions!.handleAction!({
|
||||
channel: "whatsapp",
|
||||
action: "react",
|
||||
params: { emoji: "👍", to: "+1555" },
|
||||
cfg: baseCfg,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
toolContext: {
|
||||
currentChannelProvider: "whatsapp",
|
||||
currentMessageId: "ctx-msg-42",
|
||||
},
|
||||
}).catch((e: unknown) => e);
|
||||
// WhatsApp source but no currentChannelId to compare → fallback suppressed
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).name).toBe("ToolInputError");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
|
||||
import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { chunkText } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import {
|
||||
|
|
@ -153,13 +154,39 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
|
|||
return { actions: Array.from(actions) };
|
||||
},
|
||||
supportsAction: ({ action }) => action === "react",
|
||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||
if (action !== "react") {
|
||||
throw new Error(`Action ${action} is not supported for provider ${WHATSAPP_CHANNEL}.`);
|
||||
}
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
// Only fall back to the inbound message id when the current turn
|
||||
// originates from WhatsApp and targets the same chat. Skip the
|
||||
// fallback when the source is another provider (the message id
|
||||
// would be meaningless) or when the caller routes to a different
|
||||
// WhatsApp chat (the id would belong to the wrong conversation).
|
||||
const isWhatsAppSource = toolContext?.currentChannelProvider === WHATSAPP_CHANNEL;
|
||||
const explicitTarget =
|
||||
readStringParam(params, "chatJid") ?? readStringParam(params, "to");
|
||||
const normalizedTarget = explicitTarget ? normalizeWhatsAppTarget(explicitTarget) : null;
|
||||
const normalizedCurrent =
|
||||
isWhatsAppSource && toolContext?.currentChannelId
|
||||
? normalizeWhatsAppTarget(toolContext.currentChannelId)
|
||||
: null;
|
||||
// When an explicit target is provided, require a known current chat
|
||||
// to compare against. If currentChannelId is missing/unparseable,
|
||||
// treat it as ineligible for fallback to avoid cross-chat leaks.
|
||||
const isCrossChat =
|
||||
normalizedTarget != null &&
|
||||
(normalizedCurrent == null || normalizedTarget !== normalizedCurrent);
|
||||
const scopedContext = !isWhatsAppSource || isCrossChat ? undefined : toolContext;
|
||||
const messageIdRaw = resolveReactionMessageId({
|
||||
args: params,
|
||||
toolContext: scopedContext,
|
||||
});
|
||||
if (messageIdRaw == null) {
|
||||
// Delegate to readStringParam so the gateway maps the error to 400.
|
||||
readStringParam(params, "messageId", { required: true });
|
||||
}
|
||||
const messageId = String(messageIdRaw);
|
||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
|
||||
return await getWhatsAppRuntime().channel.whatsapp.handleWhatsAppAction(
|
||||
|
|
|
|||
Loading…
Reference in New Issue