diff --git a/runtime/application/gateway/inbound_service.py b/runtime/application/gateway/inbound_service.py index cc9b0a5c..0bbe3906 100644 --- a/runtime/application/gateway/inbound_service.py +++ b/runtime/application/gateway/inbound_service.py @@ -707,6 +707,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: bot_jid=bot_jid, require_mention=group_policy.require_mention, triggers=list(group_policy.triggers), + metadata=inbound.metadata if isinstance(inbound.metadata, dict) else {}, ): return {"ok": True, "replies": []} reply = "" diff --git a/runtime/operations/whatsapp_bridge/baileys_runner.ts b/runtime/operations/whatsapp_bridge/baileys_runner.ts index 298de001..8be62346 100644 --- a/runtime/operations/whatsapp_bridge/baileys_runner.ts +++ b/runtime/operations/whatsapp_bridge/baileys_runner.ts @@ -45,34 +45,74 @@ function ensureDir(dir: string): void { } function pickText(m: proto.IMessage | null | undefined): string { - if (!m) return ""; - const c = (m.conversation || "").trim(); + const u = unwrapMessage(m); + if (!u) return ""; + const c = (u.conversation || "").trim(); if (c) return c; - const ext = (m.extendedTextMessage?.text || "").trim(); + const ext = (u.extendedTextMessage?.text || "").trim(); if (ext) return ext; - const imgCap = (m.imageMessage?.caption || "").trim(); + const imgCap = (u.imageMessage?.caption || "").trim(); if (imgCap) return imgCap; - const vidCap = (m.videoMessage?.caption || "").trim(); + const vidCap = (u.videoMessage?.caption || "").trim(); if (vidCap) return vidCap; return ""; } -function isStatusOrBroadcastJid(jid: string): boolean { - const low = String(jid || "").toLowerCase(); - return low === "status@broadcast" || low.endsWith("@broadcast"); +function unwrapMessage(m: proto.IMessage | null | undefined): proto.IMessage | null { + if (!m) return null; + const nested = + m.ephemeralMessage?.message || + m.viewOnceMessage?.message || + m.viewOnceMessageV2?.message || + m.documentWithCaptionMessage?.message || + m.editedMessage?.message || + null; + if (nested) return unwrapMessage(nested); + return m; +} + +function messageContextInfo(m: proto.IMessage | null | undefined): proto.IContextInfo | null | undefined { + const u = unwrapMessage(m); + if (!u) return null; + return ( + u.extendedTextMessage?.contextInfo || + u.imageMessage?.contextInfo || + u.videoMessage?.contextInfo || + u.documentMessage?.contextInfo || + u.buttonsResponseMessage?.contextInfo || + u.listResponseMessage?.contextInfo || + u.templateButtonReplyMessage?.contextInfo || + null + ); +} + +function jidPhone(jid: string): string { + const head = String(jid || "").split("@")[0]?.split(":")[0] || ""; + return head.replace(/\D/g, ""); +} + +function jidsSameUser(a: string, b: string): boolean { + const na = jidNormalizedUser(String(a || "").trim()); + const nb = jidNormalizedUser(String(b || "").trim()); + if (na && nb && na === nb) return true; + const pa = jidPhone(a); + const pb = jidPhone(b); + return pa.length >= 6 && pa === pb; } function extractMentions(m: proto.IMessage | null | undefined): string[] { - if (!m) return []; - const ctx = - m.extendedTextMessage?.contextInfo || - m.imageMessage?.contextInfo || - m.videoMessage?.contextInfo || - m.documentMessage?.contextInfo || - null; + const ctx = messageContextInfo(m); const raw = ctx?.mentionedJid; if (!Array.isArray(raw)) return []; - return raw.map((j) => jidNormalizedUser(String(j || "").trim())).filter(Boolean); + return raw.map((j) => String(j || "").trim()).filter(Boolean); +} + +function extractQuoteContext(m: proto.IMessage | null | undefined): { participant: string; stanzaId: string } { + const ctx = messageContextInfo(m); + return { + participant: String(ctx?.participant || "").trim(), + stanzaId: String(ctx?.stanzaId || "").trim(), + }; } function shouldProcessGroupMessage(params: { @@ -80,16 +120,18 @@ function shouldProcessGroupMessage(params: { text: string; mentions: string[]; botJid: string; + quotedParticipant?: string; }): boolean { if (!params.isGroup) return true; - const bot = jidNormalizedUser(params.botJid || ""); + const bot = String(params.botJid || "").trim(); + if (bot && params.quotedParticipant && jidsSameUser(bot, params.quotedParticipant)) { + return true; + } if (bot) { - const botUser = bot.split("@")[0] || ""; for (const m of params.mentions || []) { - const norm = jidNormalizedUser(String(m || "").trim()); - if (!norm) continue; - if (norm === bot) return true; - if (botUser && norm.split("@")[0] === botUser) return true; + const raw = String(m || "").trim(); + if (!raw) continue; + if (jidsSameUser(raw, bot)) return true; } } const body = String(params.text || ""); @@ -99,6 +141,11 @@ function shouldProcessGroupMessage(params: { return !GROUP_REQUIRE_MENTION; } +function isStatusOrBroadcastJid(jid: string): boolean { + const low = String(jid || "").toLowerCase(); + return low === "status@broadcast" || low.endsWith("@broadcast"); +} + function buildInboundPayload(params: { chatId: string; userId: string; @@ -155,9 +202,9 @@ function readMentionJids(meta: Json): string[] { const raw = (meta as any).mention_jids ?? (meta as any).mentionJids; if (!Array.isArray(raw)) { const single = String((meta as any).reply_to_user_id || (meta as any).replyToUserId || "").trim(); - return single ? [jidNormalizedUser(single)] : []; + return single ? [single] : []; } - return raw.map((j) => jidNormalizedUser(String(j || "").trim())).filter(Boolean); + return raw.map((j) => String(j || "").trim()).filter(Boolean); } function buildMentionPrefix(mentionJids: string[]): string { @@ -179,7 +226,7 @@ function buildQuotedMessage(params: { const stanzaId = String(params.stanzaId || "").trim(); const chatId = String(params.chatId || "").trim(); if (!stanzaId || !chatId) return undefined; - const participant = jidNormalizedUser(String(params.participant || "").trim()); + const participant = String(params.participant || "").trim(); const quoteText = String(params.quoteText || "").trim() || "..."; return { key: { @@ -270,11 +317,16 @@ async function sendReplyWithAttachments(params: { } if (outText) { - await s.sendMessage( - params.deliverTo, - textOpts.content as any, - textOpts.quoted ? { quoted: textOpts.quoted } : undefined, - ); + try { + await s.sendMessage( + params.deliverTo, + textOpts.content as any, + textOpts.quoted ? { quoted: textOpts.quoted } : undefined, + ); + } catch (err) { + log(`send reply failed (${String(err)}); retry plain text`); + await s.sendMessage(params.deliverTo, { text: outText }); + } } } @@ -457,14 +509,18 @@ async function main(): Promise { const userId = from ? jidNormalizedUser(from) : jidNormalizedUser(remoteJid); const chatId = jidNormalizedUser(remoteJid); const mentions = extractMentions(msg.message); - const botJid = sock?.user?.id ? jidNormalizedUser(sock.user.id) : ""; + const quote = extractQuoteContext(msg.message); + const botJidRaw = sock?.user?.id ? String(sock.user.id).trim() : ""; + const botJid = botJidRaw ? jidNormalizedUser(botJidRaw) : ""; + const isReplyToBot = Boolean(botJidRaw && quote.participant && jidsSameUser(botJidRaw, quote.participant)); if ( !shouldProcessGroupMessage({ isGroup, text, mentions, - botJid, + botJid: botJidRaw, + quotedParticipant: quote.participant, }) ) { if (VERBOSE) log(`skip group message chat=${chatId} user=${userId} (no mention/trigger)`); @@ -479,6 +535,9 @@ async function main(): Promise { participant: key.participant || null, pushName: (msg as any).pushName || null, messageTimestamp: (msg as any).messageTimestamp || null, + quotedParticipant: quote.participant || null, + quotedStanzaId: quote.stanzaId || null, + isReplyToBot, }; const inbound = buildInboundPayload({ diff --git a/runtime/orchestration/group_ingest.py b/runtime/orchestration/group_ingest.py index c98b26d5..5ee43816 100644 --- a/runtime/orchestration/group_ingest.py +++ b/runtime/orchestration/group_ingest.py @@ -28,6 +28,44 @@ def normalize_jid(jid: str) -> str: return s.split(":")[0] +def jid_phone(jid: str) -> str: + head = str(jid or "").strip().split("@", 1)[0] + head = head.split(":", 1)[0] + return re.sub(r"\D", "", head) + + +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 + pa = jid_phone(a) + pb = jid_phone(b) + return len(pa) >= 6 and pa == pb + + +def _metadata_raw(metadata: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(metadata, dict): + return {} + raw = metadata.get("raw") + return raw if isinstance(raw, dict) else {} + + +def is_reply_to_bot(*, metadata: dict[str, Any] | None, bot_jid: str | None) -> bool: + raw = _metadata_raw(metadata) + if raw.get("isReplyToBot") is True or raw.get("is_reply_to_bot") is True: + return True + quoted = "" + if isinstance(metadata, dict): + quoted = str(metadata.get("quoted_participant") or "").strip() + if not quoted: + quoted = str(raw.get("quotedParticipant") or raw.get("quoted_participant") or "").strip() + bot = str(bot_jid or "").strip() + if quoted and bot and jids_same_user(quoted, bot): + return True + return False + + def normalize_jids(jids: list[str]) -> set[str]: out: set[str] = set() for raw in jids or []: @@ -127,9 +165,12 @@ def should_process_group_inbound( bot_jid: str | None, require_mention: bool = True, triggers: list[str] | tuple[str, ...] | None = None, + metadata: dict[str, Any] | None = None, ) -> bool: if not is_group: return True + if is_reply_to_bot(metadata=metadata, bot_jid=bot_jid): + return True mention_set = normalize_jids(list(mentions or [])) bot = normalize_jid(str(bot_jid or "")) if bot and bot in mention_set: @@ -140,6 +181,10 @@ def should_process_group_inbound( 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 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): @@ -165,19 +210,19 @@ def build_whatsapp_group_reply_metadata( """Outbound hints for WhatsApp sidecar: @ sender + quote original message.""" meta = inbound.metadata if isinstance(getattr(inbound, "metadata", None), dict) else {} raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {} - sender_jid = normalize_jid(str(getattr(inbound, "external_user_id", "") or "")) + sender_jid = str(getattr(inbound, "external_user_id", "") or "").strip() chat_id = str(getattr(inbound, "external_chat_id", "") or "").strip() + participant_raw = str(raw.get("participant") or sender_jid or "").strip() stanza_id = str(raw.get("id") or meta.get("message_id") or "").strip() - participant = normalize_jid(str(raw.get("participant") or sender_jid or "")) quote_text = str(getattr(inbound, "text", "") or "").strip() push_name = str(raw.get("pushName") or meta.get("push_name") or "").strip() out: dict[str, Any] = { "is_group": True, - "reply_to_user_id": sender_jid, - "mention_jids": [sender_jid] if sender_jid else [], + "reply_to_user_id": participant_raw or normalize_jid(sender_jid), + "mention_jids": [participant_raw] if participant_raw else ([normalize_jid(sender_jid)] if sender_jid else []), "quote_remote_jid": chat_id, "quote_stanza_id": stanza_id, - "quote_participant": participant, + "quote_participant": participant_raw or normalize_jid(str(raw.get("participant") or sender_jid or "")), "quote_text": quote_text, } if push_name: diff --git a/tests/test_group_ingest.py b/tests/test_group_ingest.py index 44c84d72..18a43fbb 100644 --- a/tests/test_group_ingest.py +++ b/tests/test_group_ingest.py @@ -68,6 +68,33 @@ def test_should_accept_group_when_bot_mentioned() -> None: ) +def test_should_accept_group_mention_with_lid_phone_match() -> None: + assert ( + should_process_group_inbound( + is_group=True, + text="@bot hello", + mentions=["999@lid"], + bot_jid="999@s.whatsapp.net", + require_mention=True, + ) + is True + ) + + +def test_should_accept_group_reply_to_bot() -> None: + assert ( + should_process_group_inbound( + is_group=True, + text="follow up", + mentions=[], + bot_jid="999@s.whatsapp.net", + require_mention=True, + metadata={"raw": {"quotedParticipant": "999:0@s.whatsapp.net", "isReplyToBot": True}}, + ) + is True + ) + + def test_should_accept_group_trigger_without_mention() -> None: assert ( should_process_group_inbound( @@ -207,9 +234,9 @@ def test_build_whatsapp_group_reply_metadata() -> None: ) meta = build_whatsapp_group_reply_metadata(inbound=inbound) assert meta["quote_stanza_id"] == "MSG123" - assert meta["mention_jids"] == ["111@s.whatsapp.net"] + assert meta["mention_jids"] == ["111:12@s.whatsapp.net"] assert meta["quote_text"] == "明天几点?" - assert meta["quote_participant"] == "111@s.whatsapp.net" + assert meta["quote_participant"] == "111:12@s.whatsapp.net" def test_shared_group_session_for_multiple_senders(fresh_sqlite_store: SqliteStore) -> None: