Skip to content

Instantly share code, notes, and snippets.

@omarshahine
Created March 14, 2026 18:01
Show Gist options
  • Select an option

  • Save omarshahine/eb2efb77f5150478a6ba424e02eaa71b to your computer and use it in GitHub Desktop.

Select an option

Save omarshahine/eb2efb77f5150478a6ba424e02eaa71b to your computer and use it in GitHub Desktop.
fix(whatsapp): resolve phone→LID for group reactions — openclaw/openclaw#36090
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)
#!/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