diff --git a/runtime/application/gateway/inbound_service.py b/runtime/application/gateway/inbound_service.py index 0bbe3906..2baf0445 100644 --- a/runtime/application/gateway/inbound_service.py +++ b/runtime/application/gateway/inbound_service.py @@ -709,6 +709,16 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: triggers=list(group_policy.triggers), metadata=inbound.metadata if isinstance(inbound.metadata, dict) else {}, ): + import logging + + logging.getLogger(__name__).info( + "whatsapp group inbound skipped chat=%s user=%s mentions=%s require_mention=%s text=%r", + inbound.external_chat_id, + inbound.external_user_id, + list(inbound.mentions or []), + group_policy.require_mention, + text[:120], + ) return {"ok": True, "replies": []} reply = "" reply_attachments: list[dict[str, Any]] = [] diff --git a/runtime/operations/scripts/whatsapp_start.ps1 b/runtime/operations/scripts/whatsapp_start.ps1 index 17370812..0b647b27 100644 --- a/runtime/operations/scripts/whatsapp_start.ps1 +++ b/runtime/operations/scripts/whatsapp_start.ps1 @@ -35,6 +35,16 @@ if (-not (Test-Path (Join-Path $sidecarRoot "baileys_runner.ts"))) { throw "whatsapp sidecar not installed. Run whatsapp_install.ps1 first." } +$bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\whatsapp_bridge" +if (Test-Path $bridgeSrc) { + foreach ($name in @("baileys_runner.ts", "auth.ts", "qr.ts")) { + $srcPath = Join-Path $bridgeSrc $name + if (Test-Path $srcPath) { + Copy-Item -Path $srcPath -Destination (Join-Path $sidecarRoot $name) -Force + } + } +} + $healthUrl = ($GatewayBaseUrl.TrimEnd("/") + "/health") for ($i = 0; $i -lt $GatewayWaitSeconds; $i++) { try { diff --git a/runtime/operations/whatsapp_bridge/baileys_runner.ts b/runtime/operations/whatsapp_bridge/baileys_runner.ts index 8be62346..8a2df8e2 100644 --- a/runtime/operations/whatsapp_bridge/baileys_runner.ts +++ b/runtime/operations/whatsapp_bridge/baileys_runner.ts @@ -30,12 +30,6 @@ const PROXY_URL = ( process.env.http_proxy || "" ).trim(); -const GROUP_REQUIRE_MENTION = String(process.env.AIA_WHATSAPP_GROUP_REQUIRE_MENTION ?? "1").trim() !== "0"; -const GROUP_TRIGGERS = String(process.env.AIA_WHATSAPP_GROUP_TRIGGERS || "/oclaw,|oclaw") - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - function log(msg: string): void { process.stdout.write(`${new Date().toISOString()} [baileys-whatsapp] ${msg}\n`); } @@ -74,7 +68,10 @@ function unwrapMessage(m: proto.IMessage | null | undefined): proto.IMessage | n function messageContextInfo(m: proto.IMessage | null | undefined): proto.IContextInfo | null | undefined { const u = unwrapMessage(m); if (!u) return null; + const top = (u as proto.IMessage & { messageContextInfo?: proto.IMessageContextInfo }).messageContextInfo; + const fromTop = top?.mentionedJid?.length ? top : null; return ( + (fromTop as unknown as proto.IContextInfo) || u.extendedTextMessage?.contextInfo || u.imageMessage?.contextInfo || u.videoMessage?.contextInfo || @@ -86,6 +83,23 @@ function messageContextInfo(m: proto.IMessage | null | undefined): proto.IContex ); } +function extractMentionsFromUpsert(msg: proto.IWebMessageInfo): string[] { + const fromBody = extractMentions(msg.message); + const outer = (msg as proto.IWebMessageInfo & { messageContextInfo?: { mentionedJid?: string[] } }) + .messageContextInfo?.mentionedJid; + const outerList = Array.isArray(outer) ? outer.map((j) => String(j || "").trim()).filter(Boolean) : []; + const merged = [...fromBody, ...outerList]; + const seen = new Set(); + const out: string[] = []; + for (const j of merged) { + const key = jidPhone(j) || j; + if (seen.has(key)) continue; + seen.add(key); + out.push(j); + } + return out; +} + function jidPhone(jid: string): string { const head = String(jid || "").split("@")[0]?.split(":")[0] || ""; return head.replace(/\D/g, ""); @@ -115,32 +129,6 @@ function extractQuoteContext(m: proto.IMessage | null | undefined): { participan }; } -function shouldProcessGroupMessage(params: { - isGroup: boolean; - text: string; - mentions: string[]; - botJid: string; - quotedParticipant?: string; -}): boolean { - if (!params.isGroup) return true; - const bot = String(params.botJid || "").trim(); - if (bot && params.quotedParticipant && jidsSameUser(bot, params.quotedParticipant)) { - return true; - } - if (bot) { - for (const m of params.mentions || []) { - const raw = String(m || "").trim(); - if (!raw) continue; - if (jidsSameUser(raw, bot)) return true; - } - } - const body = String(params.text || ""); - for (const t of GROUP_TRIGGERS) { - if (t && body.includes(t)) return true; - } - return !GROUP_REQUIRE_MENTION; -} - function isStatusOrBroadcastJid(jid: string): boolean { const low = String(jid || "").toLowerCase(); return low === "status@broadcast" || low.endsWith("@broadcast"); @@ -508,25 +496,12 @@ async function main(): Promise { const from = isGroup ? String(key.participant || "").trim() : remoteJid; const userId = from ? jidNormalizedUser(from) : jidNormalizedUser(remoteJid); const chatId = jidNormalizedUser(remoteJid); - const mentions = extractMentions(msg.message); + const mentions = extractMentionsFromUpsert(msg); 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: botJidRaw, - quotedParticipant: quote.participant, - }) - ) { - if (VERBOSE) log(`skip group message chat=${chatId} user=${userId} (no mention/trigger)`); - continue; - } - const groupName = isGroup ? await resolveGroupName(chatId) : ""; const raw = { @@ -548,9 +523,15 @@ async function main(): Promise { isGroup, mentions, groupName: groupName || undefined, - botJid: botJid || undefined, + botJid: botJidRaw || botJid || undefined, }); - if (VERBOSE) log(`inbound posting chat=${chatId} user=${userId} textLen=${text.length}`); + if (VERBOSE || isGroup) { + log( + `inbound group=${isGroup} chat=${chatId} user=${userId} mentions=${mentions.length} replyToBot=${isReplyToBot} textLen=${text.length}`, + ); + } else if (VERBOSE) { + log(`inbound posting chat=${chatId} user=${userId} textLen=${text.length}`); + } const out = await postInbound(inbound); const replies = Array.isArray(out.replies) ? (out.replies as Json[]) : []; if (VERBOSE) log(`inbound ok replies=${replies.length}`); diff --git a/runtime/orchestration/group_ingest.py b/runtime/orchestration/group_ingest.py index 5ee43816..bfe4ccca 100644 --- a/runtime/orchestration/group_ingest.py +++ b/runtime/orchestration/group_ingest.py @@ -51,6 +51,21 @@ def _metadata_raw(metadata: dict[str, Any] | None) -> dict[str, Any]: return raw if isinstance(raw, dict) else {} +def text_mentions_bot(*, text: str, bot_jid: str | None) -> bool: + """Fallback when WhatsApp omits mentionedJid but user visibly @-mentions the bot.""" + bot = str(bot_jid or "").strip() + if not bot: + return False + phone = jid_phone(bot) + if len(phone) < 6: + return False + body = str(text or "") + if "@" not in body: + return False + digits_in_text = re.sub(r"\D", "", body) + return phone in digits_in_text + + 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: @@ -185,6 +200,8 @@ def should_process_group_inbound( 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): @@ -244,4 +261,5 @@ __all__ = [ "session_user_key", "should_process_group_inbound", "should_send_channel_reply_text", + "text_mentions_bot", ] diff --git a/tests/test_group_ingest.py b/tests/test_group_ingest.py index 18a43fbb..501377ea 100644 --- a/tests/test_group_ingest.py +++ b/tests/test_group_ingest.py @@ -12,6 +12,7 @@ from runtime.orchestration.group_ingest import ( resolve_group_policy, session_user_key, should_process_group_inbound, + text_mentions_bot, ) from interfaces.channels.base import InboundMessage from runtime.application.gateway.inbound_service import _parse_generic_inbound, process_inbound_payload @@ -95,6 +96,11 @@ def test_should_accept_group_reply_to_bot() -> None: ) +def test_text_mentions_bot_phone_fallback() -> None: + assert text_mentions_bot(text="@1234567890 hello", bot_jid="1234567890@s.whatsapp.net") is True + assert text_mentions_bot(text="hello", bot_jid="1234567890@s.whatsapp.net") is False + + def test_should_accept_group_trigger_without_mention() -> None: assert ( should_process_group_inbound(