fix(whatsapp): only reply in groups when bot is among @mentions

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-05 14:14:13 +08:00
parent 847567f495
commit 9b58479e92
3 changed files with 139 additions and 73 deletions

View file

@ -6,7 +6,6 @@ import dns from "node:dns/promises";
import makeWASocket, { import makeWASocket, {
Browsers, Browsers,
DisconnectReason, DisconnectReason,
areJidsSameUser,
fetchLatestBaileysVersion, fetchLatestBaileysVersion,
jidNormalizedUser, jidNormalizedUser,
proto, proto,
@ -146,23 +145,27 @@ function jidBaseLocal(jid: string): string {
return s.split("@")[0]?.split(":")[0] || ""; return s.split("@")[0]?.split(":")[0] || "";
} }
function jidsReferSameUser(a: string, b: string): boolean { function mentionMatchesBotIdentity(mention: string, botId: string): boolean {
const aa = String(a || "").trim(); const m = String(mention || "").trim();
const bb = String(b || "").trim(); const b = String(botId || "").trim();
if (!aa || !bb) return false; if (!m || !b) return false;
try { if (jidNormalizedUser(m) === jidNormalizedUser(b)) return true;
if (areJidsSameUser(aa, bb)) return true; const mLid = m.toLowerCase().endsWith("@lid");
} catch { const bLid = b.toLowerCase().endsWith("@lid");
// ignore if (mLid || bLid) {
const ml = jidBaseLocal(m);
const bl = jidBaseLocal(b);
if (ml && bl && ml === bl && ml.replace(/\D/g, "").length >= 6) return true;
return false;
} }
if (jidsSameUser(aa, bb)) return true; const mp = jidPhone(m);
const la = jidBaseLocal(aa); const bp = jidPhone(b);
const lb = jidBaseLocal(bb); return (
if (la && lb && la === lb) { mp.length >= 6 &&
const digits = la.replace(/\D/g, ""); mp === bp &&
if (digits.length >= 6) return true; m.toLowerCase().includes("@s.whatsapp") &&
} b.toLowerCase().includes("@s.whatsapp")
return false; );
} }
function collectBotIdentityJids( function collectBotIdentityJids(
@ -218,24 +221,11 @@ function messageMentionsBot(
): boolean { ): boolean {
const ids = botIds?.length ? botIds : collectBotIdentityJids(sock, botPn, authCreds); const ids = botIds?.length ? botIds : collectBotIdentityJids(sock, botPn, authCreds);
if (!ids.length || !mentions.length) return false; if (!ids.length || !mentions.length) return false;
const lidMapping = (sock as any)?.signalRepository?.lidMapping;
for (const m of mentions) { for (const m of mentions) {
const mention = String(m || "").trim(); const mention = String(m || "").trim();
if (!mention) continue; if (!mention) continue;
for (const botId of ids) { for (const botId of ids) {
if (jidsReferSameUser(mention, botId)) return true; if (mentionMatchesBotIdentity(mention, botId)) return true;
}
if (lidMapping && typeof lidMapping.getPNForLID === "function" && mention.toLowerCase().endsWith("@lid")) {
try {
const pn = lidMapping.getPNForLID(mention);
if (pn) {
for (const botId of ids) {
if (jidsReferSameUser(String(pn), botId)) return true;
}
}
} catch {
// ignore
}
} }
} }
return false; return false;
@ -639,8 +629,8 @@ async function main(): Promise<void> {
const isReplyToBot = Boolean( const isReplyToBot = Boolean(
botJidRaw && botJidRaw &&
quote.participant && quote.participant &&
(messageMentionsBot(sock, [quote.participant], botJidRaw, botIdentityJids) || !mentions.length &&
jidsSameUser(botJidRaw, quote.participant)), botIdentityJids.some((botId) => mentionMatchesBotIdentity(quote.participant, botId)),
); );
const groupName = isGroup ? await resolveGroupName(chatId) : ""; const groupName = isGroup ? await resolveGroupName(chatId) : "";

View file

@ -88,7 +88,7 @@ def _metadata_raw(metadata: dict[str, Any] | None) -> dict[str, Any]:
def metadata_mentions_bot(metadata: dict[str, Any] | None) -> bool: def metadata_mentions_bot(metadata: dict[str, Any] | None) -> bool:
"""Sidecar resolves LID @-mentions via Baileys; gateway trusts that signal.""" """Sidecar hint when WhatsApp omits mentionedJid (display-name @)."""
if not isinstance(metadata, dict): if not isinstance(metadata, dict):
return False return False
if metadata.get("mentions_bot") is True or metadata.get("mentionsBot") is True: if metadata.get("mentions_bot") is True or metadata.get("mentionsBot") is True:
@ -97,6 +97,25 @@ def metadata_mentions_bot(metadata: dict[str, Any] | None) -> bool:
return raw.get("mentionsBot") is True or raw.get("mentions_bot") is True return raw.get("mentionsBot") is True or raw.get("mentions_bot") is True
def mentions_include_bot(
*,
mentions: list[str],
bot_jid: str | None,
metadata: dict[str, Any] | None = None,
) -> bool:
identities = _bot_identity_jids(bot_jid=bot_jid, metadata=metadata)
if not identities:
return False
for raw in mentions or []:
mention = str(raw or "").strip()
if not mention:
continue
for identity in identities:
if jids_same_user(mention, identity):
return True
return False
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()
@ -230,44 +249,28 @@ def should_process_group_inbound(
) -> bool: ) -> bool:
if not is_group: if not is_group:
return True return True
mentions_list = [str(m).strip() for m in (mentions or []) if str(m).strip()]
trigger_list = [str(t) for t in (triggers or []) if str(t)]
body = str(text or "")
has_trigger = bool(trigger_list and any(t in body for t in trigger_list))
bot_mentioned = mentions_include_bot(
mentions=mentions_list,
bot_jid=bot_jid,
metadata=metadata,
) or text_mentions_bot(text=text, bot_jid=bot_jid)
# Multi-@: reply only when the bot is among mentionedJid; @ others alone stays silent.
if mentions_list:
if bot_mentioned:
return True
return has_trigger
if metadata_mentions_bot(metadata): if metadata_mentions_bot(metadata):
return True 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
for identity in _bot_identity_jids(bot_jid=bot_jid, metadata=metadata): if has_trigger:
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:
return True
# Baileys may omit bot from mentionedJid when user uses display-name @; compare user part.
if bot and "@" 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
if bot:
for m in list(mentions or []):
if jids_same_user(str(m or ""), bot):
return True
if text_mentions_bot(text=text, bot_jid=bot_jid):
return True
trigger_list = [str(t) for t in (triggers or []) if str(t)]
body = str(text or "")
if trigger_list and any(t in body for t in trigger_list):
return True return True
return not require_mention return not require_mention
@ -315,6 +318,7 @@ __all__ = [
"GroupPolicyConfig", "GroupPolicyConfig",
"build_group_sender_context", "build_group_sender_context",
"build_whatsapp_group_reply_metadata", "build_whatsapp_group_reply_metadata",
"mentions_include_bot",
"metadata_mentions_bot", "metadata_mentions_bot",
"normalize_jid", "normalize_jid",
"normalize_jids", "normalize_jids",

View file

@ -74,8 +74,8 @@ def test_should_accept_group_mention_with_lid_phone_match() -> None:
should_process_group_inbound( should_process_group_inbound(
is_group=True, is_group=True,
text="@bot hello", text="@bot hello",
mentions=["999@lid"], mentions=["6281284654304@lid"],
bot_jid="999@s.whatsapp.net", bot_jid="6281284654304@s.whatsapp.net",
require_mention=True, require_mention=True,
) )
is True is True
@ -102,12 +102,12 @@ def test_jids_same_user_lid_device_suffix() -> None:
assert jids_same_user("176944565977182@lid", "176944565977182:2@lid") is True assert jids_same_user("176944565977182@lid", "176944565977182:2@lid") is True
def test_should_accept_group_when_sidecar_reports_mentions_bot() -> None: def test_should_accept_group_when_sidecar_reports_mentions_bot_without_jids() -> None:
assert ( assert (
should_process_group_inbound( should_process_group_inbound(
is_group=True, is_group=True,
text="hi", text="@bot hi",
mentions=["unknown-lid@lid"], mentions=[],
bot_jid="999@s.whatsapp.net", bot_jid="999@s.whatsapp.net",
require_mention=True, require_mention=True,
metadata={"mentions_bot": True, "raw": {"mentionsBot": True}}, metadata={"mentions_bot": True, "raw": {"mentionsBot": True}},
@ -116,6 +116,78 @@ def test_should_accept_group_when_sidecar_reports_mentions_bot() -> None:
) )
def test_should_reject_group_when_multiple_others_mentioned_without_bot() -> None:
assert (
should_process_group_inbound(
is_group=True,
text="@alice @bob 开会",
mentions=[
"111111111111@lid",
"222222222222@lid",
],
bot_jid="6281284654304@s.whatsapp.net",
require_mention=True,
metadata={"bot_lid": "176944565977182:2@lid"},
)
is False
)
def test_should_accept_group_when_bot_among_multiple_mentions() -> None:
assert (
should_process_group_inbound(
is_group=True,
text="@alice @bot 帮忙",
mentions=[
"111111111111@lid",
"176944565977182@lid",
],
bot_jid="6281284654304@s.whatsapp.net",
require_mention=True,
metadata={"bot_lid": "176944565977182:2@lid"},
)
is True
)
def test_should_reject_group_when_only_other_user_mentioned() -> None:
assert (
should_process_group_inbound(
is_group=True,
text="@alice hello",
mentions=["333465375410398@lid"],
bot_jid="6281284654304@s.whatsapp.net",
require_mention=True,
metadata={
"mentions_bot": True,
"bot_lid": "176944565977182:2@lid",
"raw": {"mentionsBot": True, "isReplyToBot": True},
},
)
is False
)
def test_should_reject_group_when_other_mentioned_even_if_reply_to_bot() -> None:
assert (
should_process_group_inbound(
is_group=True,
text="@alice follow up",
mentions=["333465375410398@lid"],
bot_jid="6281284654304@s.whatsapp.net",
require_mention=True,
metadata={
"bot_lid": "176944565977182:2@lid",
"raw": {
"quotedParticipant": "6281284654304@s.whatsapp.net",
"isReplyToBot": True,
},
},
)
is False
)
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(