From 4f5f965f090dd37355540c297fa0ba60555fd776 Mon Sep 17 00:00:00 2001 From: dvp <1204069+danielphang@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:02:39 -0700 Subject: [PATCH] fix(whatsapp): handle LID group mentions (#2663) Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com> --- bridge/src/whatsapp.ts | 60 +++++++++++++++++-------- nanobot/channels/whatsapp.py | 7 ++- tests/channels/test_whatsapp_channel.py | 50 +++++++++++++++++++++ 3 files changed, 97 insertions(+), 20 deletions(-) diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index 0d2f40b2e..46dcbe4c9 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -26,10 +26,12 @@ export interface InboundMessage { id: string; sender: string; pn: string; + participant?: string; content: string; timestamp: number; isGroup: boolean; wasMentioned?: boolean; + isReplyToBot?: boolean; media?: string[]; } @@ -50,28 +52,49 @@ export class WhatsAppClient { } private normalizeJid(jid: string | undefined | null): string { - return (jid || '').split(':')[0]; + return (jid || '').trim().toLowerCase().replace(/:\d+(?=@)/g, ''); } - private wasMentioned(msg: any): boolean { - if (!msg?.key?.remoteJid?.endsWith('@g.us')) return false; - - const candidates = [ - msg?.message?.extendedTextMessage?.contextInfo?.mentionedJid, - msg?.message?.imageMessage?.contextInfo?.mentionedJid, - msg?.message?.videoMessage?.contextInfo?.mentionedJid, - msg?.message?.documentMessage?.contextInfo?.mentionedJid, - msg?.message?.audioMessage?.contextInfo?.mentionedJid, - ]; - const mentioned = candidates.flatMap((items) => (Array.isArray(items) ? items : [])); - if (mentioned.length === 0) return false; - - const selfIds = new Set( + private selfJids(): Set { + return new Set( [this.sock?.user?.id, this.sock?.user?.lid, this.sock?.user?.jid] .map((jid) => this.normalizeJid(jid)) .filter(Boolean), ); - return mentioned.some((jid: string) => selfIds.has(this.normalizeJid(jid))); + } + + private messageContextInfos(msg: any): any[] { + const unwrapped = baileysExtractMessageContent(msg?.message); + const containers = [msg?.message, unwrapped]; + const infos = containers.flatMap((message) => [ + message?.extendedTextMessage?.contextInfo, + message?.imageMessage?.contextInfo, + message?.videoMessage?.contextInfo, + message?.documentMessage?.contextInfo, + message?.audioMessage?.contextInfo, + ]); + return infos.filter(Boolean); + } + + private botAddressing(msg: any): { wasMentioned: boolean; isReplyToBot: boolean } { + if (!msg?.key?.remoteJid?.endsWith('@g.us')) { + return { wasMentioned: false, isReplyToBot: false }; + } + + const selfIds = this.selfJids(); + const contextInfos = this.messageContextInfos(msg); + + const mentioned = contextInfos.flatMap((info) => ( + Array.isArray(info?.mentionedJid) ? info.mentionedJid : [] + )); + const wasMentioned = mentioned.some((jid: string) => selfIds.has(this.normalizeJid(jid))); + + const isReplyToBot = contextInfos.some((info) => { + const quotedParticipant = this.normalizeJid(info?.participant); + return Boolean(info?.stanzaId && quotedParticipant && selfIds.has(quotedParticipant)); + }); + + return { wasMentioned, isReplyToBot }; } async connect(): Promise { @@ -175,16 +198,17 @@ export class WhatsAppClient { if (!finalContent && mediaPaths.length === 0) continue; const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false; - const wasMentioned = this.wasMentioned(msg); + const { wasMentioned, isReplyToBot } = this.botAddressing(msg); this.options.onMessage({ id: msg.key.id || '', sender: msg.key.remoteJid || '', pn: msg.key.remoteJidAlt || '', + ...(isGroup && msg.key.participant ? { participant: msg.key.participant } : {}), content: finalContent, timestamp: msg.messageTimestamp as number, isGroup, - ...(isGroup ? { wasMentioned } : {}), + ...(isGroup ? { wasMentioned: wasMentioned || isReplyToBot, isReplyToBot } : {}), ...(mediaPaths.length > 0 ? { media: mediaPaths } : {}), }); } diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 39134689d..268b62f31 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -216,7 +216,7 @@ class WhatsAppChannel(BaseChannel): # Extract just the phone number or lid as chat_id is_group = data.get("isGroup", False) - was_mentioned = data.get("wasMentioned", False) + was_mentioned = bool(data.get("wasMentioned", False) or data.get("isReplyToBot", False)) if is_group and getattr(self.config, "group_policy", "open") == "mention": if not was_mentioned: @@ -225,7 +225,8 @@ class WhatsAppChannel(BaseChannel): # Classify by JID suffix: @s.whatsapp.net = phone, @lid.whatsapp.net = LID # The bridge's pn/sender fields don't consistently map to phone/LID across versions. raw_a = pn or "" - raw_b = sender or "" + participant = data.get("participant", "") + raw_b = participant or sender or "" id_a = raw_a.split("@")[0] if "@" in raw_a else raw_a id_b = raw_b.split("@")[0] if "@" in raw_b else raw_b @@ -289,6 +290,8 @@ class WhatsAppChannel(BaseChannel): "message_id": message_id, "timestamp": data.get("timestamp"), "is_group": data.get("isGroup", False), + "participant": participant or None, + "is_reply_to_bot": data.get("isReplyToBot", False), }, ) diff --git a/tests/channels/test_whatsapp_channel.py b/tests/channels/test_whatsapp_channel.py index 6229723a5..5032ca410 100644 --- a/tests/channels/test_whatsapp_channel.py +++ b/tests/channels/test_whatsapp_channel.py @@ -163,6 +163,32 @@ async def test_group_policy_mention_accepts_mentioned_group_message(): assert kwargs["sender_id"] == "user" +@pytest.mark.asyncio +async def test_group_policy_mention_accepts_reply_to_bot_message(): + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock()) + ch._handle_message = AsyncMock() + + await ch._handle_bridge_message( + json.dumps( + { + "type": "message", + "id": "m-reply", + "sender": "12345@g.us", + "pn": "user@s.whatsapp.net", + "content": "replying to bot", + "timestamp": 1, + "isGroup": True, + "wasMentioned": False, + "isReplyToBot": True, + } + ) + ) + + ch._handle_message.assert_awaited_once() + kwargs = ch._handle_message.await_args.kwargs + assert kwargs["metadata"]["is_reply_to_bot"] is True + + @pytest.mark.asyncio async def test_sender_id_prefers_phone_jid_over_lid(): """sender_id should resolve to phone number when @s.whatsapp.net JID is present.""" @@ -184,6 +210,30 @@ async def test_sender_id_prefers_phone_jid_over_lid(): assert kwargs["sender_id"] == "5551234" +@pytest.mark.asyncio +async def test_group_sender_id_uses_participant_when_phone_jid_missing(): + """Group messages should identify the participant, not the group chat JID.""" + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["SENDERLID"]}, MagicMock()) + ch._handle_message = AsyncMock() + + await ch._handle_bridge_message( + json.dumps({ + "type": "message", + "id": "group-lid", + "sender": "12345@g.us", + "pn": "", + "participant": "SENDERLID@lid.whatsapp.net", + "content": "hi", + "timestamp": 1, + "isGroup": True, + }) + ) + + kwargs = ch._handle_message.await_args.kwargs + assert kwargs["sender_id"] == "SENDERLID" + assert kwargs["metadata"]["participant"] == "SENDERLID@lid.whatsapp.net" + + @pytest.mark.asyncio async def test_lid_to_phone_cache_resolves_lid_only_messages(): """When only LID is present, a cached LID→phone mapping should be used."""