fix(whatsapp): resolve LID @-mentions in sidecar and trust mentions_bot in gateway

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-04 20:01:45 +08:00
parent dcb779a691
commit 8c665290c4
4 changed files with 99 additions and 7 deletions

View file

@ -672,6 +672,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
account = store.find_user_by_channel_account(channel=inbound.channel, account_id=account_id) or {} account = store.find_user_by_channel_account(channel=inbound.channel, account_id=account_id) or {}
from runtime.orchestration.group_ingest import ( from runtime.orchestration.group_ingest import (
build_group_sender_context, build_group_sender_context,
metadata_mentions_bot,
resolve_group_policy, resolve_group_policy,
session_user_key, session_user_key,
should_process_group_inbound, should_process_group_inbound,
@ -712,10 +713,11 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
import logging import logging
logging.getLogger(__name__).info( logging.getLogger(__name__).info(
"whatsapp group inbound skipped chat=%s user=%s mentions=%s require_mention=%s text=%r", "whatsapp group inbound skipped chat=%s user=%s mentions=%s mentions_bot=%s require_mention=%s text=%r",
inbound.external_chat_id, inbound.external_chat_id,
inbound.external_user_id, inbound.external_user_id,
list(inbound.mentions or []), list(inbound.mentions or []),
metadata_mentions_bot(inbound.metadata if isinstance(inbound.metadata, dict) else {}),
group_policy.require_mention, group_policy.require_mention,
text[:120], text[:120],
) )

View file

@ -6,6 +6,7 @@ import dns from "node:dns/promises";
import makeWASocket, { import makeWASocket, {
Browsers, Browsers,
DisconnectReason, DisconnectReason,
areJidsSameUser,
fetchLatestBaileysVersion, fetchLatestBaileysVersion,
jidNormalizedUser, jidNormalizedUser,
proto, proto,
@ -129,6 +130,53 @@ function extractQuoteContext(m: proto.IMessage | null | undefined): { participan
}; };
} }
function resolveSenderJid(key: proto.IMessageKey): string {
const participant = String(key.participant || "").trim();
const participantAlt = String((key as any).participantAlt || "").trim();
if (participantAlt && participant.toLowerCase().endsWith("@lid")) {
return jidNormalizedUser(participantAlt);
}
if (participant) return jidNormalizedUser(participant);
return "";
}
function messageMentionsBot(
sock: ReturnType<typeof makeWASocket> | null,
mentions: string[],
botJid: string,
): boolean {
const bot = String(botJid || "").trim();
if (!bot || !mentions.length) return false;
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")) {
try {
const pn = lidMapping.getPNForLID(mention);
if (pn && (areJidsSameUser(String(pn), bot) || jidsSameUser(String(pn), bot))) return true;
} catch {
// ignore
}
}
if (lidMapping && typeof lidMapping.getLIDForPN === "function" && bot.includes("@s.whatsapp")) {
try {
const lid = lidMapping.getLIDForPN(bot);
if (lid && (areJidsSameUser(mention, String(lid)) || jidsSameUser(mention, String(lid)))) return true;
} catch {
// ignore
}
}
}
return false;
}
function isStatusOrBroadcastJid(jid: string): boolean { function isStatusOrBroadcastJid(jid: string): boolean {
const low = String(jid || "").toLowerCase(); const low = String(jid || "").toLowerCase();
return low === "status@broadcast" || low.endsWith("@broadcast"); return low === "status@broadcast" || low.endsWith("@broadcast");
@ -143,13 +191,16 @@ function buildInboundPayload(params: {
mentions: string[]; mentions: string[];
groupName?: string; groupName?: string;
botJid?: string; botJid?: string;
mentionsBot?: boolean;
}): Json { }): Json {
const metadata: Json = { const metadata: Json = {
source: "whatsapp_baileys", source: "whatsapp_baileys",
raw: params.raw, raw: params.raw,
mentions_bot: params.mentionsBot === true,
}; };
if (params.groupName) metadata.group_name = params.groupName; if (params.groupName) metadata.group_name = params.groupName;
if (params.botJid) metadata.bot_jid = params.botJid; if (params.botJid) metadata.bot_jid = params.botJid;
if (params.mentions.length) metadata.mentioned_jids = params.mentions;
return { return {
channel: "whatsapp", channel: "whatsapp",
account_id: ACCOUNT_ID, account_id: ACCOUNT_ID,
@ -493,26 +544,37 @@ async function main(): Promise<void> {
if (!text) continue; if (!text) continue;
const isGroup = remoteJid.endsWith("@g.us"); const isGroup = remoteJid.endsWith("@g.us");
const from = isGroup ? String(key.participant || "").trim() : remoteJid; const participantRaw = String(key.participant || "").trim();
const userId = from ? jidNormalizedUser(from) : jidNormalizedUser(remoteJid); const participantAlt = String((key as any).participantAlt || "").trim();
const userId = isGroup
? resolveSenderJid(key) || jidNormalizedUser(remoteJid)
: jidNormalizedUser(remoteJid);
const chatId = jidNormalizedUser(remoteJid); const chatId = jidNormalizedUser(remoteJid);
const mentions = extractMentionsFromUpsert(msg); const mentions = extractMentionsFromUpsert(msg);
const quote = extractQuoteContext(msg.message); const quote = extractQuoteContext(msg.message);
const botJidRaw = sock?.user?.id ? String(sock.user.id).trim() : ""; const botJidRaw = sock?.user?.id ? String(sock.user.id).trim() : "";
const botJid = botJidRaw ? jidNormalizedUser(botJidRaw) : ""; const botJid = botJidRaw ? jidNormalizedUser(botJidRaw) : "";
const isReplyToBot = Boolean(botJidRaw && quote.participant && jidsSameUser(botJidRaw, quote.participant)); const mentionsBot = messageMentionsBot(sock, mentions, botJidRaw);
const isReplyToBot = Boolean(
botJidRaw &&
quote.participant &&
(messageMentionsBot(sock, [quote.participant], botJidRaw) || jidsSameUser(botJidRaw, quote.participant)),
);
const groupName = isGroup ? await resolveGroupName(chatId) : ""; const groupName = isGroup ? await resolveGroupName(chatId) : "";
const raw = { const raw = {
id, id,
remoteJid, remoteJid,
participant: key.participant || null, participant: participantRaw || null,
participantAlt: participantAlt || null,
pushName: (msg as any).pushName || null, pushName: (msg as any).pushName || null,
messageTimestamp: (msg as any).messageTimestamp || null, messageTimestamp: (msg as any).messageTimestamp || null,
quotedParticipant: quote.participant || null, quotedParticipant: quote.participant || null,
quotedStanzaId: quote.stanzaId || null, quotedStanzaId: quote.stanzaId || null,
isReplyToBot, isReplyToBot,
mentionsBot,
mentionedJids: mentions,
}; };
const inbound = buildInboundPayload({ const inbound = buildInboundPayload({
@ -524,17 +586,18 @@ async function main(): Promise<void> {
mentions, mentions,
groupName: groupName || undefined, groupName: groupName || undefined,
botJid: botJidRaw || botJid || undefined, botJid: botJidRaw || botJid || undefined,
mentionsBot,
}); });
if (VERBOSE || isGroup) { if (VERBOSE || isGroup) {
log( log(
`inbound group=${isGroup} chat=${chatId} user=${userId} mentions=${mentions.length} replyToBot=${isReplyToBot} textLen=${text.length}`, `inbound group=${isGroup} chat=${chatId} user=${userId} mentions=${mentions.length} mentionsBot=${mentionsBot} replyToBot=${isReplyToBot} textLen=${text.length} mention0=${mentions[0] || ""}`,
); );
} else if (VERBOSE) { } else if (VERBOSE) {
log(`inbound posting chat=${chatId} user=${userId} textLen=${text.length}`); log(`inbound posting chat=${chatId} user=${userId} textLen=${text.length}`);
} }
const out = await postInbound(inbound); const out = await postInbound(inbound);
const replies = Array.isArray(out.replies) ? (out.replies as Json[]) : []; const replies = Array.isArray(out.replies) ? (out.replies as Json[]) : [];
if (VERBOSE) log(`inbound ok replies=${replies.length}`); if (isGroup || VERBOSE) log(`inbound ok chat=${chatId} replies=${replies.length}`);
for (const r of replies) { for (const r of replies) {
const outText = String((r as any).text || "").trim(); const outText = String((r as any).text || "").trim();
if (!shouldSendOutboundText(outText) && !(Array.isArray((r as any).attachments) && (r as any).attachments.length)) { if (!shouldSendOutboundText(outText) && !(Array.isArray((r as any).attachments) && (r as any).attachments.length)) {

View file

@ -51,6 +51,16 @@ def _metadata_raw(metadata: dict[str, Any] | None) -> dict[str, Any]:
return raw if isinstance(raw, dict) else {} return raw if isinstance(raw, dict) else {}
def metadata_mentions_bot(metadata: dict[str, Any] | None) -> bool:
"""Sidecar resolves LID @-mentions via Baileys; gateway trusts that signal."""
if not isinstance(metadata, dict):
return False
if metadata.get("mentions_bot") is True or metadata.get("mentionsBot") is True:
return True
raw = _metadata_raw(metadata)
return raw.get("mentionsBot") is True or raw.get("mentions_bot") is True
def text_mentions_bot(*, text: str, bot_jid: str | None) -> bool: def text_mentions_bot(*, text: str, bot_jid: str | None) -> bool:
"""Fallback when WhatsApp omits mentionedJid but user visibly @-mentions the bot.""" """Fallback when WhatsApp omits mentionedJid but user visibly @-mentions the bot."""
bot = str(bot_jid or "").strip() bot = str(bot_jid or "").strip()
@ -184,6 +194,8 @@ def should_process_group_inbound(
) -> bool: ) -> bool:
if not is_group: if not is_group:
return True return True
if metadata_mentions_bot(metadata):
return True
if is_reply_to_bot(metadata=metadata, bot_jid=bot_jid): if is_reply_to_bot(metadata=metadata, bot_jid=bot_jid):
return True return True
mention_set = normalize_jids(list(mentions or [])) mention_set = normalize_jids(list(mentions or []))
@ -252,6 +264,7 @@ __all__ = [
"GroupPolicyConfig", "GroupPolicyConfig",
"build_group_sender_context", "build_group_sender_context",
"build_whatsapp_group_reply_metadata", "build_whatsapp_group_reply_metadata",
"metadata_mentions_bot",
"normalize_jid", "normalize_jid",
"normalize_jids", "normalize_jids",
"infer_is_group_from_chat_id", "infer_is_group_from_chat_id",

View file

@ -82,6 +82,20 @@ def test_should_accept_group_mention_with_lid_phone_match() -> None:
) )
def test_should_accept_group_when_sidecar_reports_mentions_bot() -> None:
assert (
should_process_group_inbound(
is_group=True,
text="hi",
mentions=["unknown-lid@lid"],
bot_jid="999@s.whatsapp.net",
require_mention=True,
metadata={"mentions_bot": True, "raw": {"mentionsBot": True}},
)
is True
)
def test_should_accept_group_reply_to_bot() -> None: def test_should_accept_group_reply_to_bot() -> None:
assert ( assert (
should_process_group_inbound( should_process_group_inbound(