Skip to content

Commit e6580d7

Browse files
committed
WhatsApp: use shared resolveReactionMessageId for context-aware reactions
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
1 parent 798e5f9 commit e6580d7

3 files changed

Lines changed: 178 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
77
### Changes
88

99
- 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.
10+
- 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.
1011

1112
### Fixes
1213

extensions/whatsapp/src/channel.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type { OpenClawConfig } from "./runtime-api.js";
2323

2424
const hoisted = vi.hoisted(() => ({
2525
sendPollWhatsApp: vi.fn(async () => ({ messageId: "wa-poll-1", toJid: "1555@s.whatsapp.net" })),
26+
handleWhatsAppAction: vi.fn(async () => ({ content: [{ type: "text", text: '{"ok":true}' }] })),
2627
loginWeb: vi.fn(async () => {}),
2728
pathExists: vi.fn(async () => false),
2829
listWhatsAppAccountIds: vi.fn(() => [] as string[]),
@@ -40,6 +41,7 @@ vi.mock("./runtime.js", () => ({
4041
channel: {
4142
whatsapp: {
4243
sendPollWhatsApp: hoisted.sendPollWhatsApp,
44+
handleWhatsAppAction: hoisted.handleWhatsAppAction,
4345
},
4446
},
4547
}),
@@ -428,3 +430,148 @@ describe("whatsapp group policy", () => {
428430
});
429431
});
430432
});
433+
434+
describe("whatsappPlugin actions.handleAction react messageId resolution", () => {
435+
const baseCfg = {
436+
channels: { whatsapp: { actions: { reactions: true }, allowFrom: ["*"] } },
437+
} as OpenClawConfig;
438+
439+
beforeEach(() => {
440+
hoisted.handleWhatsAppAction.mockClear();
441+
});
442+
443+
it("uses explicit messageId when provided", async () => {
444+
await whatsappPlugin.actions!.handleAction!({
445+
channel: "whatsapp",
446+
action: "react",
447+
params: { messageId: "explicit-id", emoji: "👍", to: "+1555" },
448+
cfg: baseCfg,
449+
accountId: DEFAULT_ACCOUNT_ID,
450+
});
451+
expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith(
452+
expect.objectContaining({ messageId: "explicit-id" }),
453+
baseCfg,
454+
);
455+
});
456+
457+
it("falls back to toolContext.currentMessageId when messageId omitted", async () => {
458+
await whatsappPlugin.actions!.handleAction!({
459+
channel: "whatsapp",
460+
action: "react",
461+
params: { emoji: "❤️", to: "+1555" },
462+
cfg: baseCfg,
463+
accountId: DEFAULT_ACCOUNT_ID,
464+
toolContext: {
465+
currentChannelId: "whatsapp:+1555",
466+
currentChannelProvider: "whatsapp",
467+
currentMessageId: "ctx-msg-42",
468+
},
469+
});
470+
expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith(
471+
expect.objectContaining({ messageId: "ctx-msg-42" }),
472+
baseCfg,
473+
);
474+
});
475+
476+
it("converts numeric toolContext messageId to string", async () => {
477+
await whatsappPlugin.actions!.handleAction!({
478+
channel: "whatsapp",
479+
action: "react",
480+
params: { emoji: "🎉", to: "+1555" },
481+
cfg: baseCfg,
482+
accountId: DEFAULT_ACCOUNT_ID,
483+
toolContext: {
484+
currentChannelId: "whatsapp:+1555",
485+
currentChannelProvider: "whatsapp",
486+
currentMessageId: 12345,
487+
},
488+
});
489+
expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith(
490+
expect.objectContaining({ messageId: "12345" }),
491+
baseCfg,
492+
);
493+
});
494+
495+
it("throws ToolInputError when messageId missing and no toolContext", async () => {
496+
const err = await whatsappPlugin.actions!.handleAction!({
497+
channel: "whatsapp",
498+
action: "react",
499+
params: { emoji: "👍", to: "+1555" },
500+
cfg: baseCfg,
501+
accountId: DEFAULT_ACCOUNT_ID,
502+
}).catch((e: unknown) => e);
503+
expect(err).toBeInstanceOf(Error);
504+
expect((err as Error).name).toBe("ToolInputError");
505+
});
506+
507+
it("skips context fallback when targeting a different chat", async () => {
508+
const err = await whatsappPlugin.actions!.handleAction!({
509+
channel: "whatsapp",
510+
action: "react",
511+
params: { emoji: "👍", to: "+9999" },
512+
cfg: baseCfg,
513+
accountId: DEFAULT_ACCOUNT_ID,
514+
toolContext: {
515+
currentChannelId: "whatsapp:+1555",
516+
currentChannelProvider: "whatsapp",
517+
currentMessageId: "ctx-msg-42",
518+
},
519+
}).catch((e: unknown) => e);
520+
// Different target chat → context fallback suppressed → ToolInputError
521+
expect(err).toBeInstanceOf(Error);
522+
expect((err as Error).name).toBe("ToolInputError");
523+
});
524+
525+
it("uses context fallback when target matches current chat (prefixed)", async () => {
526+
await whatsappPlugin.actions!.handleAction!({
527+
channel: "whatsapp",
528+
action: "react",
529+
params: { emoji: "👍", to: "+1555" },
530+
cfg: baseCfg,
531+
accountId: DEFAULT_ACCOUNT_ID,
532+
toolContext: {
533+
currentChannelId: "whatsapp:+1555",
534+
currentChannelProvider: "whatsapp",
535+
currentMessageId: "ctx-msg-42",
536+
},
537+
});
538+
expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith(
539+
expect.objectContaining({ messageId: "ctx-msg-42" }),
540+
baseCfg,
541+
);
542+
});
543+
544+
it("skips context fallback when source is another provider", async () => {
545+
const err = await whatsappPlugin.actions!.handleAction!({
546+
channel: "whatsapp",
547+
action: "react",
548+
params: { emoji: "👍", to: "+1555" },
549+
cfg: baseCfg,
550+
accountId: DEFAULT_ACCOUNT_ID,
551+
toolContext: {
552+
currentChannelId: "telegram:-1003841603622",
553+
currentChannelProvider: "telegram",
554+
currentMessageId: "tg-msg-99",
555+
},
556+
}).catch((e: unknown) => e);
557+
expect(err).toBeInstanceOf(Error);
558+
expect((err as Error).name).toBe("ToolInputError");
559+
});
560+
561+
it("skips context fallback when currentChannelId is missing with explicit target", async () => {
562+
const err = await whatsappPlugin.actions!.handleAction!({
563+
channel: "whatsapp",
564+
action: "react",
565+
params: { emoji: "👍", to: "+1555" },
566+
cfg: baseCfg,
567+
accountId: DEFAULT_ACCOUNT_ID,
568+
toolContext: {
569+
currentChannelProvider: "whatsapp",
570+
currentMessageId: "ctx-msg-42",
571+
},
572+
}).catch((e: unknown) => e);
573+
// WhatsApp source but no currentChannelId to compare → fallback suppressed
574+
expect(err).toBeInstanceOf(Error);
575+
expect((err as Error).name).toBe("ToolInputError");
576+
});
577+
});

extensions/whatsapp/src/channel.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
2+
import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
23
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
34
import { chunkText } from "openclaw/plugin-sdk/reply-runtime";
45
import {
@@ -153,13 +154,39 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
153154
return { actions: Array.from(actions) };
154155
},
155156
supportsAction: ({ action }) => action === "react",
156-
handleAction: async ({ action, params, cfg, accountId }) => {
157+
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
157158
if (action !== "react") {
158159
throw new Error(`Action ${action} is not supported for provider ${WHATSAPP_CHANNEL}.`);
159160
}
160-
const messageId = readStringParam(params, "messageId", {
161-
required: true,
161+
// Only fall back to the inbound message id when the current turn
162+
// originates from WhatsApp and targets the same chat. Skip the
163+
// fallback when the source is another provider (the message id
164+
// would be meaningless) or when the caller routes to a different
165+
// WhatsApp chat (the id would belong to the wrong conversation).
166+
const isWhatsAppSource = toolContext?.currentChannelProvider === WHATSAPP_CHANNEL;
167+
const explicitTarget =
168+
readStringParam(params, "chatJid") ?? readStringParam(params, "to");
169+
const normalizedTarget = explicitTarget ? normalizeWhatsAppTarget(explicitTarget) : null;
170+
const normalizedCurrent =
171+
isWhatsAppSource && toolContext?.currentChannelId
172+
? normalizeWhatsAppTarget(toolContext.currentChannelId)
173+
: null;
174+
// When an explicit target is provided, require a known current chat
175+
// to compare against. If currentChannelId is missing/unparseable,
176+
// treat it as ineligible for fallback to avoid cross-chat leaks.
177+
const isCrossChat =
178+
normalizedTarget != null &&
179+
(normalizedCurrent == null || normalizedTarget !== normalizedCurrent);
180+
const scopedContext = !isWhatsAppSource || isCrossChat ? undefined : toolContext;
181+
const messageIdRaw = resolveReactionMessageId({
182+
args: params,
183+
toolContext: scopedContext,
162184
});
185+
if (messageIdRaw == null) {
186+
// Delegate to readStringParam so the gateway maps the error to 400.
187+
readStringParam(params, "messageId", { required: true });
188+
}
189+
const messageId = String(messageIdRaw);
163190
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
164191
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
165192
return await getWhatsAppRuntime().channel.whatsapp.handleWhatsAppAction(

0 commit comments

Comments
 (0)