Created
March 14, 2026 18:01
-
-
Save omarshahine/eb2efb77f5150478a6ba424e02eaa71b to your computer and use it in GitHub Desktop.
fix(whatsapp): resolve phone→LID for group reactions — openclaw/openclaw#36090
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| From fad771cfefdc36108e02771aead22ca6e968f187 Mon Sep 17 00:00:00 2001 | |
| From: Lobster <lobster@shahine.com> | |
| Date: Thu, 5 Mar 2026 07:00:24 -0800 | |
| Subject: [PATCH 1/3] fix(whatsapp): resolve phone-based participant to LID for | |
| group reactions | |
| WhatsApp migrated group message keys from phone-based JIDs to LID-based | |
| JIDs. When the agent sends a reaction with a phone number as participant, | |
| the reaction key doesn't match the original message key, causing a silent | |
| failure. Use Baileys' built-in getLIDForPN from the signal key store to | |
| resolve phone numbers to LIDs at reaction time. | |
| Fixes #36090 | |
| Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> | |
| --- | |
| src/web/inbound/monitor.ts | 2 + | |
| src/web/inbound/send-api.test.ts | 87 ++++++++++++++++++++++++++++++++ | |
| src/web/inbound/send-api.ts | 22 +++++++- | |
| 3 files changed, 110 insertions(+), 1 deletion(-) | |
| diff --git a/src/web/inbound/monitor.ts b/src/web/inbound/monitor.ts | |
| index 6dc2ce5f5..c88dec10b 100644 | |
| --- a/src/web/inbound/monitor.ts | |
| +++ b/src/web/inbound/monitor.ts | |
| @@ -451,6 +451,8 @@ export async function monitorWebInbox(options: { | |
| sendPresenceUpdate: (presence, jid?: string) => sock.sendPresenceUpdate(presence, jid), | |
| }, | |
| defaultAccountId: options.accountId, | |
| + getLIDForPN: (pn: string) => | |
| + sock.signalRepository?.lidMapping?.getLIDForPN?.(pn) ?? Promise.resolve(null), | |
| }); | |
| return { | |
| diff --git a/src/web/inbound/send-api.test.ts b/src/web/inbound/send-api.test.ts | |
| index daa44a3c6..139abbc93 100644 | |
| --- a/src/web/inbound/send-api.test.ts | |
| +++ b/src/web/inbound/send-api.test.ts | |
| @@ -155,4 +155,91 @@ describe("createWebSendApi", () => { | |
| await api.sendComposingTo("+1555"); | |
| expect(sendPresenceUpdate).toHaveBeenCalledWith("composing", "1555@s.whatsapp.net"); | |
| }); | |
| + | |
| + it("resolves phone-based participant to LID for group reactions", async () => { | |
| + const getLIDForPN = vi.fn(async () => "99999@lid"); | |
| + const apiWithLid = createWebSendApi({ | |
| + sock: { sendMessage, sendPresenceUpdate }, | |
| + defaultAccountId: "main", | |
| + getLIDForPN, | |
| + }); | |
| + await apiWithLid.sendReaction("group@g.us", "msg-3", "❤️", false, "+1999"); | |
| + expect(getLIDForPN).toHaveBeenCalledWith("1999@s.whatsapp.net"); | |
| + expect(sendMessage).toHaveBeenCalledWith( | |
| + "group@g.us", | |
| + expect.objectContaining({ | |
| + react: { | |
| + text: "❤️", | |
| + key: expect.objectContaining({ | |
| + participant: "99999@lid", | |
| + }), | |
| + }, | |
| + }), | |
| + ); | |
| + }); | |
| + | |
| + it("falls back to phone JID when getLIDForPN returns null", async () => { | |
| + const getLIDForPN = vi.fn(async () => null); | |
| + const apiWithLid = createWebSendApi({ | |
| + sock: { sendMessage, sendPresenceUpdate }, | |
| + defaultAccountId: "main", | |
| + getLIDForPN, | |
| + }); | |
| + await apiWithLid.sendReaction("group@g.us", "msg-4", "👍", false, "+1999"); | |
| + expect(sendMessage).toHaveBeenCalledWith( | |
| + "group@g.us", | |
| + expect.objectContaining({ | |
| + react: { | |
| + text: "👍", | |
| + key: expect.objectContaining({ | |
| + participant: "1999@s.whatsapp.net", | |
| + }), | |
| + }, | |
| + }), | |
| + ); | |
| + }); | |
| + | |
| + it("skips LID lookup for non-group reactions", async () => { | |
| + const getLIDForPN = vi.fn(async () => "99999@lid"); | |
| + const apiWithLid = createWebSendApi({ | |
| + sock: { sendMessage, sendPresenceUpdate }, | |
| + defaultAccountId: "main", | |
| + getLIDForPN, | |
| + }); | |
| + await apiWithLid.sendReaction("+1555", "msg-5", "👍", false, "+1999"); | |
| + expect(getLIDForPN).not.toHaveBeenCalled(); | |
| + expect(sendMessage).toHaveBeenCalledWith( | |
| + "1555@s.whatsapp.net", | |
| + expect.objectContaining({ | |
| + react: { | |
| + text: "👍", | |
| + key: expect.objectContaining({ | |
| + participant: "1999@s.whatsapp.net", | |
| + }), | |
| + }, | |
| + }), | |
| + ); | |
| + }); | |
| + | |
| + it("skips LID lookup when participant is already a LID", async () => { | |
| + const getLIDForPN = vi.fn(async () => "99999@lid"); | |
| + const apiWithLid = createWebSendApi({ | |
| + sock: { sendMessage, sendPresenceUpdate }, | |
| + defaultAccountId: "main", | |
| + getLIDForPN, | |
| + }); | |
| + await apiWithLid.sendReaction("group@g.us", "msg-6", "👍", false, "12345@lid"); | |
| + expect(getLIDForPN).not.toHaveBeenCalled(); | |
| + expect(sendMessage).toHaveBeenCalledWith( | |
| + "group@g.us", | |
| + expect.objectContaining({ | |
| + react: { | |
| + text: "👍", | |
| + key: expect.objectContaining({ | |
| + participant: "12345@lid", | |
| + }), | |
| + }, | |
| + }), | |
| + ); | |
| + }); | |
| }); | |
| diff --git a/src/web/inbound/send-api.ts b/src/web/inbound/send-api.ts | |
| index f0e5ea764..92d743d92 100644 | |
| --- a/src/web/inbound/send-api.ts | |
| +++ b/src/web/inbound/send-api.ts | |
| @@ -23,6 +23,8 @@ export function createWebSendApi(params: { | |
| sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise<unknown>; | |
| }; | |
| defaultAccountId: string; | |
| + /** Resolve a phone-based JID (e.g. 12069106512@s.whatsapp.net) to its LID. */ | |
| + getLIDForPN?: (pn: string) => Promise<string | null>; | |
| }) { | |
| return { | |
| sendMessage: async ( | |
| @@ -93,6 +95,24 @@ export function createWebSendApi(params: { | |
| participant?: string, | |
| ): Promise<void> => { | |
| const jid = toWhatsappJid(chatJid); | |
| + // For group messages, the reaction key must include the original sender's | |
| + // participant JID. WhatsApp migrated group keys to LID-based JIDs, but | |
| + // callers typically supply a phone number. Resolve it to the LID via | |
| + // Baileys' signal key store so the reaction key matches the message key. | |
| + let resolvedParticipant = participant; | |
| + if (jid.endsWith("@g.us") && participant && params.getLIDForPN) { | |
| + const participantJid = toWhatsappJid(participant); | |
| + if (participantJid.endsWith("@s.whatsapp.net")) { | |
| + try { | |
| + const lid = await params.getLIDForPN(participantJid); | |
| + if (lid) { | |
| + resolvedParticipant = lid; | |
| + } | |
| + } catch { | |
| + // Fall through to phone-based JID. | |
| + } | |
| + } | |
| + } | |
| await params.sock.sendMessage(jid, { | |
| react: { | |
| text: emoji, | |
| @@ -100,7 +120,7 @@ export function createWebSendApi(params: { | |
| remoteJid: jid, | |
| id: messageId, | |
| fromMe, | |
| - participant: participant ? toWhatsappJid(participant) : undefined, | |
| + participant: resolvedParticipant ? toWhatsappJid(resolvedParticipant) : undefined, | |
| }, | |
| }, | |
| } as AnyMessageContent); | |
| -- | |
| 2.50.1 (Apple Git-155) | |
| From 5bdb03e41c1c3bdba116e27dac44be0363deff1e Mon Sep 17 00:00:00 2001 | |
| From: Lobster <lobster@shahine.com> | |
| Date: Thu, 5 Mar 2026 04:45:40 -0800 | |
| Subject: [PATCH 2/3] fix(whatsapp): auto-populate participant for group | |
| reactions from requesterSenderId | |
| When reacting to messages in WhatsApp groups, the participant field (sender JID) | |
| is required by Baileys to build the correct message key. Without it the reaction | |
| silently fails (API returns ok but nothing happens). | |
| The gateway already provides the sender JID via requesterSenderId in the | |
| ChannelMessageActionContext. This change uses it as a fallback when the model | |
| omits participant, matching the pattern used by other channel action handlers. | |
| Closes #36090 | |
| Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> | |
| --- | |
| extensions/whatsapp/src/channel.ts | 4 ++-- | |
| 1 file changed, 2 insertions(+), 2 deletions(-) | |
| diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts | |
| index 424c1046c..46f5703f8 100644 | |
| --- a/extensions/whatsapp/src/channel.ts | |
| +++ b/extensions/whatsapp/src/channel.ts | |
| @@ -253,7 +253,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = { | |
| return Array.from(actions); | |
| }, | |
| supportsAction: ({ action }) => action === "react", | |
| - handleAction: async ({ action, params, cfg, accountId }) => { | |
| + handleAction: async ({ action, params, cfg, accountId, requesterSenderId }) => { | |
| if (action !== "react") { | |
| throw new Error(`Action ${action} is not supported for provider ${meta.id}.`); | |
| } | |
| @@ -270,7 +270,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = { | |
| messageId, | |
| emoji, | |
| remove, | |
| - participant: readStringParam(params, "participant"), | |
| + participant: readStringParam(params, "participant") ?? requesterSenderId ?? undefined, | |
| accountId: accountId ?? undefined, | |
| fromMe: typeof params.fromMe === "boolean" ? params.fromMe : undefined, | |
| }, | |
| -- | |
| 2.50.1 (Apple Git-155) | |
| From ebcbf87ed36379ffef2a57a442abddc55dd5d33a Mon Sep 17 00:00:00 2001 | |
| From: Lobster <lobster@shahine.com> | |
| Date: Thu, 5 Mar 2026 05:18:08 -0800 | |
| Subject: [PATCH 3/3] fix(whatsapp): guard participant fallback to WhatsApp JID | |
| formats only | |
| MIME-Version: 1.0 | |
| Content-Type: text/plain; charset=UTF-8 | |
| Content-Transfer-Encoding: 8bit | |
| Only use requesterSenderId as participant fallback when it matches a | |
| WhatsApp JID pattern (@s.whatsapp.net or @lid). This prevents cross-channel | |
| pollution when a non-WhatsApp context (Discord, Web UI) drives a WhatsApp | |
| reaction — toWhatsappJid would mangle those IDs into invalid participants. | |
| Also adds test coverage for LID-format fallback and the cross-channel guard. | |
| Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> | |
| --- | |
| extensions/whatsapp/src/channel.test.ts | 130 +++++++++++++++++++++++- | |
| extensions/whatsapp/src/channel.ts | 10 +- | |
| 2 files changed, 138 insertions(+), 2 deletions(-) | |
| diff --git a/extensions/whatsapp/src/channel.test.ts b/extensions/whatsapp/src/channel.test.ts | |
| index b1e13f878..b4925dfc6 100644 | |
| --- a/extensions/whatsapp/src/channel.test.ts | |
| +++ b/extensions/whatsapp/src/channel.test.ts | |
| @@ -1,5 +1,17 @@ | |
| -import { describe, expect, it, vi } from "vitest"; | |
| +import type { OpenClawConfig } from "openclaw/plugin-sdk"; | |
| +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
| import { whatsappPlugin } from "./channel.js"; | |
| +import { setWhatsAppRuntime } from "./runtime.js"; | |
| + | |
| +const handleWhatsAppAction = vi.fn(async () => ({ type: "json", data: { ok: true } })); | |
| + | |
| +function installRuntime() { | |
| + setWhatsAppRuntime({ | |
| + channel: { | |
| + whatsapp: { handleWhatsAppAction }, | |
| + }, | |
| + } as never); | |
| +} | |
| describe("whatsappPlugin outbound sendMedia", () => { | |
| it("forwards mediaLocalRoots to sendMessageWhatsApp", async () => { | |
| @@ -39,3 +51,119 @@ describe("whatsappPlugin outbound sendMedia", () => { | |
| expect(result).toMatchObject({ channel: "whatsapp", messageId: "msg-1" }); | |
| }); | |
| }); | |
| + | |
| +const enabledConfig = { | |
| + channels: { whatsapp: { actions: { reactions: true } } }, | |
| +} as OpenClawConfig; | |
| + | |
| +describe("whatsappPlugin.actions.handleAction react participant fallback", () => { | |
| + beforeEach(() => { | |
| + vi.clearAllMocks(); | |
| + installRuntime(); | |
| + }); | |
| + | |
| + it("uses requesterSenderId as participant fallback for @s.whatsapp.net JIDs", async () => { | |
| + await whatsappPlugin.actions!.handleAction!({ | |
| + channel: "whatsapp", | |
| + action: "react", | |
| + cfg: enabledConfig, | |
| + params: { | |
| + chatJid: "group123@g.us", | |
| + messageId: "msg1", | |
| + emoji: "✅", | |
| + }, | |
| + requesterSenderId: "201006884440@s.whatsapp.net", | |
| + }); | |
| + | |
| + expect(handleWhatsAppAction).toHaveBeenCalledWith( | |
| + expect.objectContaining({ | |
| + participant: "201006884440@s.whatsapp.net", | |
| + }), | |
| + enabledConfig, | |
| + ); | |
| + }); | |
| + | |
| + it("uses requesterSenderId as participant fallback for @lid JIDs", async () => { | |
| + await whatsappPlugin.actions!.handleAction!({ | |
| + channel: "whatsapp", | |
| + action: "react", | |
| + cfg: enabledConfig, | |
| + params: { | |
| + chatJid: "group123@g.us", | |
| + messageId: "msg1", | |
| + emoji: "✅", | |
| + }, | |
| + requesterSenderId: "143134247891105@lid", | |
| + }); | |
| + | |
| + expect(handleWhatsAppAction).toHaveBeenCalledWith( | |
| + expect.objectContaining({ | |
| + participant: "143134247891105@lid", | |
| + }), | |
| + enabledConfig, | |
| + ); | |
| + }); | |
| + | |
| + it("ignores requesterSenderId from non-WhatsApp contexts", async () => { | |
| + await whatsappPlugin.actions!.handleAction!({ | |
| + channel: "whatsapp", | |
| + action: "react", | |
| + cfg: enabledConfig, | |
| + params: { | |
| + chatJid: "group123@g.us", | |
| + messageId: "msg1", | |
| + emoji: "✅", | |
| + }, | |
| + requesterSenderId: "discord-user-123", | |
| + }); | |
| + | |
| + expect(handleWhatsAppAction).toHaveBeenCalledWith( | |
| + expect.objectContaining({ | |
| + participant: undefined, | |
| + }), | |
| + enabledConfig, | |
| + ); | |
| + }); | |
| + | |
| + it("prefers explicit participant param over requesterSenderId", async () => { | |
| + await whatsappPlugin.actions!.handleAction!({ | |
| + channel: "whatsapp", | |
| + action: "react", | |
| + cfg: enabledConfig, | |
| + params: { | |
| + chatJid: "group123@g.us", | |
| + messageId: "msg1", | |
| + emoji: "✅", | |
| + participant: "explicit@s.whatsapp.net", | |
| + }, | |
| + requesterSenderId: "fallback@s.whatsapp.net", | |
| + }); | |
| + | |
| + expect(handleWhatsAppAction).toHaveBeenCalledWith( | |
| + expect.objectContaining({ | |
| + participant: "explicit@s.whatsapp.net", | |
| + }), | |
| + enabledConfig, | |
| + ); | |
| + }); | |
| + | |
| + it("passes undefined participant when both param and requesterSenderId are absent", async () => { | |
| + await whatsappPlugin.actions!.handleAction!({ | |
| + channel: "whatsapp", | |
| + action: "react", | |
| + cfg: enabledConfig, | |
| + params: { | |
| + chatJid: "123@s.whatsapp.net", | |
| + messageId: "msg1", | |
| + emoji: "✅", | |
| + }, | |
| + }); | |
| + | |
| + expect(handleWhatsAppAction).toHaveBeenCalledWith( | |
| + expect.objectContaining({ | |
| + participant: undefined, | |
| + }), | |
| + enabledConfig, | |
| + ); | |
| + }); | |
| +}); | |
| diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts | |
| index 46f5703f8..5e5e2efcb 100644 | |
| --- a/extensions/whatsapp/src/channel.ts | |
| +++ b/extensions/whatsapp/src/channel.ts | |
| @@ -270,7 +270,15 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = { | |
| messageId, | |
| emoji, | |
| remove, | |
| - participant: readStringParam(params, "participant") ?? requesterSenderId ?? undefined, | |
| + // Fallback to requesterSenderId when it looks like a WhatsApp JID (@s.whatsapp.net or @lid). | |
| + // Correct when reacting to the requester's own message (the common case). | |
| + // For reactions to other participants' messages the model must supply participant explicitly. | |
| + // Guard: only use the fallback for WhatsApp-origin sender IDs to avoid cross-channel pollution. | |
| + participant: | |
| + readStringParam(params, "participant") ?? | |
| + (requesterSenderId && /\@(s\.whatsapp\.net|lid)$/.test(requesterSenderId) | |
| + ? requesterSenderId | |
| + : undefined), | |
| accountId: accountId ?? undefined, | |
| fromMe: typeof params.fromMe === "boolean" ? params.fromMe : undefined, | |
| }, | |
| -- | |
| 2.50.1 (Apple Git-155) | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # openclaw-patch-wa-group-react | |
| # Patches OpenClaw to fix WhatsApp group reaction silent failures. | |
| # Uses Baileys' LID mapping (getLIDForPN) to resolve phone→LID at reaction time. | |
| # Workaround for https://github.com/openclaw/openclaw/issues/36090 | |
| # Remove this script once the fix ships in a release. | |
| # | |
| # Usage: openclaw-patch-wa-group-react [--check] | |
| # --check Only report whether the patch is needed; don't modify files. | |
| set -euo pipefail | |
| BUNDLE_DIR="$(dirname "$(realpath "$(command -v openclaw)")")" | |
| # Find ALL channel-web bundles that contain sendReaction + monitor | |
| BUNDLES=() | |
| for f in "$BUNDLE_DIR"/dist/channel-web-*.js; do | |
| if [[ -f "$f" ]] && grep -q "sendReaction" "$f" 2>/dev/null && grep -q "monitorWebInbox\|monitorWebChannel" "$f" 2>/dev/null; then | |
| BUNDLES+=("$f") | |
| fi | |
| done | |
| if [[ ${#BUNDLES[@]} -eq 0 ]]; then | |
| echo "ERROR: Could not find channel-web bundle(s) with sendReaction + monitor" >&2 | |
| exit 1 | |
| fi | |
| # Check if all are already patched | |
| ALL_PATCHED=true | |
| for BUNDLE in "${BUNDLES[@]}"; do | |
| if ! grep -q "getLIDForPN" "$BUNDLE" 2>/dev/null; then | |
| ALL_PATCHED=false | |
| break | |
| fi | |
| done | |
| if $ALL_PATCHED; then | |
| echo "Already patched — nothing to do." | |
| exit 0 | |
| fi | |
| if [[ "${1:-}" == "--check" ]]; then | |
| echo "NEEDS PATCH." | |
| exit 1 | |
| fi | |
| for BUNDLE in "${BUNDLES[@]}"; do | |
| if grep -q "getLIDForPN" "$BUNDLE" 2>/dev/null; then | |
| echo "Skipping $BUNDLE (already patched)" | |
| continue | |
| fi | |
| echo "Patching: $BUNDLE" | |
| cp "$BUNDLE" "$BUNDLE.bak" | |
| node -e ' | |
| const fs = require("fs"); | |
| const file = process.argv[1]; | |
| let code = fs.readFileSync(file, "utf8"); | |
| // 1. Patch createWebSendApi: add getLIDForPN to the destructured params | |
| // Find the sendReaction closure and inject LID resolution before the sendMessage call | |
| const sendReactOld = `sendReaction: async (chatJid, messageId, emoji, fromMe, participant) => { | |
| \t\t\tconst jid = toWhatsappJid(chatJid); | |
| \t\t\tawait params.sock.sendMessage(jid, { react: { | |
| \t\t\t\ttext: emoji, | |
| \t\t\t\tkey: { | |
| \t\t\t\t\tremoteJid: jid, | |
| \t\t\t\t\tid: messageId, | |
| \t\t\t\t\tfromMe, | |
| \t\t\t\t\tparticipant: participant ? toWhatsappJid(participant) : void 0 | |
| \t\t\t\t} | |
| \t\t\t} });`; | |
| const sendReactNew = `sendReaction: async (chatJid, messageId, emoji, fromMe, participant) => { | |
| \t\t\tconst jid = toWhatsappJid(chatJid); | |
| \t\t\tlet resolvedParticipant = participant; | |
| \t\t\tif (jid.endsWith("@g.us") && participant && params.getLIDForPN) { | |
| \t\t\t\tconst pJid = toWhatsappJid(participant); | |
| \t\t\t\tif (pJid.endsWith("@s.whatsapp.net")) { | |
| \t\t\t\t\ttry { const lid = await params.getLIDForPN(pJid); if (lid) resolvedParticipant = lid; } catch {} | |
| \t\t\t\t} | |
| \t\t\t} | |
| \t\t\tawait params.sock.sendMessage(jid, { react: { | |
| \t\t\t\ttext: emoji, | |
| \t\t\t\tkey: { | |
| \t\t\t\t\tremoteJid: jid, | |
| \t\t\t\t\tid: messageId, | |
| \t\t\t\t\tfromMe, | |
| \t\t\t\t\tparticipant: resolvedParticipant ? toWhatsappJid(resolvedParticipant) : void 0 | |
| \t\t\t\t} | |
| \t\t\t} });`; | |
| if (!code.includes(sendReactOld)) { | |
| console.error("ERROR: could not find sendReaction code block"); | |
| process.exit(1); | |
| } | |
| code = code.replace(sendReactOld, sendReactNew); | |
| // 2. Patch createWebSendApi call in monitor to pass getLIDForPN | |
| const createApiOld = "defaultAccountId: options.accountId"; | |
| const createApiIdx = code.indexOf(createApiOld); | |
| if (createApiIdx === -1) { | |
| console.error("ERROR: could not find createWebSendApi call"); | |
| process.exit(1); | |
| } | |
| code = code.replace( | |
| createApiOld, | |
| "defaultAccountId: options.accountId,\n\t\tgetLIDForPN: (pn) => sock.signalRepository?.lidMapping?.getLIDForPN?.(pn) ?? Promise.resolve(null)" | |
| ); | |
| fs.writeFileSync(file, code); | |
| console.log("Patched successfully."); | |
| ' "$BUNDLE" | |
| done | |
| echo "" | |
| echo "Done. Restart the gateway: openclaw gateway restart" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment