fix(whatsapp): handle LID group mentions (#2663)

Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
This commit is contained in:
dvp 2026-06-07 03:02:39 -07:00 committed by GitHub
parent ab9f49970d
commit 4f5f965f09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 97 additions and 20 deletions

View File

@ -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<string> {
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<void> {
@ -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 } : {}),
});
}

View File

@ -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),
},
)

View File

@ -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."""