From 0fc05452ec1bfeb3c0e53a51a5d61b97c5c4e3c7 Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 4 Jun 2026 20:15:54 +0800 Subject: [PATCH] fix(whatsapp): match bot LID mentions ignoring device suffix Co-authored-by: Cursor --- .../whatsapp_bridge/baileys_runner.ts | 136 ++++++++++++++---- runtime/orchestration/group_ingest.py | 51 +++++++ tests/test_group_ingest.py | 20 +++ 3 files changed, 181 insertions(+), 26 deletions(-) diff --git a/runtime/operations/whatsapp_bridge/baileys_runner.ts b/runtime/operations/whatsapp_bridge/baileys_runner.ts index b5a40892..bffecee4 100644 --- a/runtime/operations/whatsapp_bridge/baileys_runner.ts +++ b/runtime/operations/whatsapp_bridge/baileys_runner.ts @@ -140,35 +140,99 @@ function resolveSenderJid(key: proto.IMessageKey): string { return ""; } -function messageMentionsBot( +function jidBaseLocal(jid: string): string { + const s = String(jid || "").trim().toLowerCase(); + if (!s) return ""; + return s.split("@")[0]?.split(":")[0] || ""; +} + +function jidsReferSameUser(a: string, b: string): boolean { + const aa = String(a || "").trim(); + const bb = String(b || "").trim(); + if (!aa || !bb) return false; + try { + if (areJidsSameUser(aa, bb)) return true; + } catch { + // ignore + } + if (jidsSameUser(aa, bb)) return true; + const la = jidBaseLocal(aa); + const lb = jidBaseLocal(bb); + if (la && lb && la === lb) { + const digits = la.replace(/\D/g, ""); + if (digits.length >= 6) return true; + } + return false; +} + +function collectBotIdentityJids( sock: ReturnType | null, - mentions: string[], - botJid: string, -): boolean { - const bot = String(botJid || "").trim(); - if (!bot || !mentions.length) return false; + botPn: string, + authCreds?: { me?: { id?: string; lid?: string } | null } | null, +): string[] { + const out = new Set(); + const pn = String(botPn || "").trim(); + if (pn) { + out.add(pn); + out.add(jidNormalizedUser(pn)); + const base = jidBaseLocal(pn); + if (base) out.add(`${base}@s.whatsapp.net`); + } + const user = (sock as any)?.user; + if (user?.id) out.add(String(user.id).trim()); + if (user?.lid) out.add(String(user.lid).trim()); + const creds = authCreds || (sock as any)?.authState?.creds; + const me = creds?.me; + if (me?.id) out.add(String(me.id).trim()); + if (me?.lid) out.add(String(me.lid).trim()); const lidMapping = (sock as any)?.signalRepository?.lidMapping; - for (const m of mentions) { - const mention = String(m || "").trim(); - if (!mention) continue; - try { - if (areJidsSameUser(mention, bot)) return true; - } catch { - // ignore - } - if (jidsSameUser(mention, bot)) return true; - if (lidMapping && typeof lidMapping.getPNForLID === "function" && mention.toLowerCase().endsWith("@lid")) { + if (lidMapping && pn) { + for (const variant of Array.from(out)) { + if (!variant.includes("@s.whatsapp")) continue; try { - const pn = lidMapping.getPNForLID(mention); - if (pn && (areJidsSameUser(String(pn), bot) || jidsSameUser(String(pn), bot))) return true; + if (typeof lidMapping.getLIDForPN === "function") { + const lid = lidMapping.getLIDForPN(variant); + if (lid) out.add(String(lid)); + } } catch { // ignore } } - if (lidMapping && typeof lidMapping.getLIDForPN === "function" && bot.includes("@s.whatsapp")) { + } + return Array.from(out).filter(Boolean); +} + +function pickBotLid(botIds: string[]): string { + for (const id of botIds) { + if (String(id || "").toLowerCase().includes("@lid")) return String(id); + } + return ""; +} + +function messageMentionsBot( + sock: ReturnType | null, + mentions: string[], + botPn: string, + botIds?: string[], + authCreds?: { me?: { id?: string; lid?: string } | null } | null, +): boolean { + const ids = botIds?.length ? botIds : collectBotIdentityJids(sock, botPn, authCreds); + if (!ids.length || !mentions.length) return false; + const lidMapping = (sock as any)?.signalRepository?.lidMapping; + for (const m of mentions) { + const mention = String(m || "").trim(); + if (!mention) continue; + for (const botId of ids) { + if (jidsReferSameUser(mention, botId)) return true; + } + if (lidMapping && typeof lidMapping.getPNForLID === "function" && mention.toLowerCase().endsWith("@lid")) { try { - const lid = lidMapping.getLIDForPN(bot); - if (lid && (areJidsSameUser(mention, String(lid)) || jidsSameUser(mention, String(lid)))) return true; + const pn = lidMapping.getPNForLID(mention); + if (pn) { + for (const botId of ids) { + if (jidsReferSameUser(String(pn), botId)) return true; + } + } } catch { // ignore } @@ -191,6 +255,7 @@ function buildInboundPayload(params: { mentions: string[]; groupName?: string; botJid?: string; + botLid?: string; mentionsBot?: boolean; }): Json { const metadata: Json = { @@ -200,6 +265,7 @@ function buildInboundPayload(params: { }; if (params.groupName) metadata.group_name = params.groupName; if (params.botJid) metadata.bot_jid = params.botJid; + if (params.botLid) metadata.bot_lid = params.botLid; if (params.mentions.length) metadata.mentioned_jids = params.mentions; return { channel: "whatsapp", @@ -460,6 +526,7 @@ async function main(): Promise { let reconnectAttempt = 0; let sock: ReturnType | null = null; + let cachedBotIdentityJids: string[] = []; const wsAgent = PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : undefined; const groupNameCache = new Map(); const GROUP_NAME_TTL_MS = 10 * 60 * 1000; @@ -492,7 +559,12 @@ async function main(): Promise { generateHighQualityLinkPreview: false, }); - sock.ev.on("creds.update", saveCreds); + sock.ev.on("creds.update", async () => { + await saveCreds(); + const meId = sock?.user?.id ? String(sock.user.id) : ""; + const ids = collectBotIdentityJids(sock, meId, state.creds); + if (ids.length) cachedBotIdentityJids = ids; + }); sock.ev.on("connection.update", async (update) => { if (update.qr) { @@ -502,7 +574,9 @@ async function main(): Promise { if (update.connection === "open") { reconnectAttempt = 0; const me = sock?.user?.id ? jidNormalizedUser(sock.user.id) : ""; - log(`connected. me=${me || "unknown"} loginOnly=${LOGIN_ONLY}`); + const botIds = collectBotIdentityJids(sock, sock?.user?.id ? String(sock.user.id) : "", state.creds); + cachedBotIdentityJids = botIds; + log(`connected. me=${me || "unknown"} botIds=${botIds.join(",") || "none"} loginOnly=${LOGIN_ONLY}`); if (LOGIN_ONLY) { log("login-only mode: exiting after successful link."); process.exit(0); @@ -554,11 +628,19 @@ async function main(): Promise { const quote = extractQuoteContext(msg.message); const botJidRaw = sock?.user?.id ? String(sock.user.id).trim() : ""; const botJid = botJidRaw ? jidNormalizedUser(botJidRaw) : ""; - const mentionsBot = messageMentionsBot(sock, mentions, botJidRaw); + const botIdentityJids = Array.from( + new Set([...cachedBotIdentityJids, ...collectBotIdentityJids(sock, botJidRaw, state.creds)]), + ); + if (botIdentityJids.length > cachedBotIdentityJids.length) { + cachedBotIdentityJids = botIdentityJids; + } + const botLid = pickBotLid(botIdentityJids); + const mentionsBot = messageMentionsBot(sock, mentions, botJidRaw, botIdentityJids); const isReplyToBot = Boolean( botJidRaw && quote.participant && - (messageMentionsBot(sock, [quote.participant], botJidRaw) || jidsSameUser(botJidRaw, quote.participant)), + (messageMentionsBot(sock, [quote.participant], botJidRaw, botIdentityJids) || + jidsSameUser(botJidRaw, quote.participant)), ); const groupName = isGroup ? await resolveGroupName(chatId) : ""; @@ -575,6 +657,7 @@ async function main(): Promise { isReplyToBot, mentionsBot, mentionedJids: mentions, + botLid: botLid || null, }; const inbound = buildInboundPayload({ @@ -586,11 +669,12 @@ async function main(): Promise { mentions, groupName: groupName || undefined, botJid: botJidRaw || botJid || undefined, + botLid: botLid || undefined, mentionsBot, }); if (VERBOSE || isGroup) { log( - `inbound group=${isGroup} chat=${chatId} user=${userId} mentions=${mentions.length} mentionsBot=${mentionsBot} replyToBot=${isReplyToBot} textLen=${text.length} mention0=${mentions[0] || ""}`, + `inbound group=${isGroup} chat=${chatId} user=${userId} mentions=${mentions.length} mentionsBot=${mentionsBot} replyToBot=${isReplyToBot} textLen=${text.length} mention0=${mentions[0] || ""} botLid=${botLid || ""}`, ); } else if (VERBOSE) { log(`inbound posting chat=${chatId} user=${userId} textLen=${text.length}`); diff --git a/runtime/orchestration/group_ingest.py b/runtime/orchestration/group_ingest.py index b54ecca2..75708404 100644 --- a/runtime/orchestration/group_ingest.py +++ b/runtime/orchestration/group_ingest.py @@ -34,16 +34,52 @@ def jid_phone(jid: str) -> str: return re.sub(r"\D", "", head) +def jid_base_local(jid: str) -> str: + s = str(jid or "").strip().lower() + if not s: + return "" + return s.split("@", 1)[0].split(":", 1)[0] + + def jids_same_user(a: str, b: str) -> bool: na = normalize_jid(a) nb = normalize_jid(b) if na and nb and na == nb: return True + la = jid_base_local(a) + lb = jid_base_local(b) + if la and lb and la == lb: + digits = re.sub(r"\D", "", la) + if len(digits) >= 6: + return True pa = jid_phone(a) pb = jid_phone(b) return len(pa) >= 6 and pa == pb +def _bot_identity_jids(*, bot_jid: str | None, metadata: dict[str, Any] | None) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for candidate in [bot_jid]: + s = str(candidate or "").strip() + if s and s not in seen: + seen.add(s) + out.append(s) + if isinstance(metadata, dict): + for key in ("bot_lid", "botLid"): + s = str(metadata.get(key) or "").strip() + if s and s not in seen: + seen.add(s) + out.append(s) + raw = _metadata_raw(metadata) + for key in ("botLid", "bot_lid"): + s = str(raw.get(key) or "").strip() + if s and s not in seen: + seen.add(s) + out.append(s) + return out + + def _metadata_raw(metadata: dict[str, Any] | None) -> dict[str, Any]: if not isinstance(metadata, dict): return {} @@ -198,6 +234,21 @@ def should_process_group_inbound( return True if is_reply_to_bot(metadata=metadata, bot_jid=bot_jid): return True + for identity in _bot_identity_jids(bot_jid=bot_jid, metadata=metadata): + bot = normalize_jid(identity) + if not bot: + continue + mention_set = normalize_jids(list(mentions or [])) + if bot in mention_set: + return True + if "@" in bot: + bot_user = bot.split("@", 1)[0] + for m in mention_set: + if "@" in m and m.split("@", 1)[0] == bot_user: + return True + for m in list(mentions or []): + if jids_same_user(str(m or ""), identity): + return True mention_set = normalize_jids(list(mentions or [])) bot = normalize_jid(str(bot_jid or "")) if bot and bot in mention_set: diff --git a/tests/test_group_ingest.py b/tests/test_group_ingest.py index 03414ca1..974b9194 100644 --- a/tests/test_group_ingest.py +++ b/tests/test_group_ingest.py @@ -82,6 +82,26 @@ def test_should_accept_group_mention_with_lid_phone_match() -> None: ) +def test_should_accept_group_mention_with_bot_lid_identity() -> None: + assert ( + should_process_group_inbound( + is_group=True, + text="@bot hello", + mentions=["176944565977182@lid"], + bot_jid="6281284654304@s.whatsapp.net", + require_mention=True, + metadata={"bot_lid": "176944565977182:2@lid"}, + ) + is True + ) + + +def test_jids_same_user_lid_device_suffix() -> None: + from runtime.orchestration.group_ingest import jids_same_user + + assert jids_same_user("176944565977182@lid", "176944565977182:2@lid") is True + + def test_should_accept_group_when_sidecar_reports_mentions_bot() -> None: assert ( should_process_group_inbound(