fix(whatsapp): pass quoted UME alarm to agent when @ bot

Extract quotedText from reply context correctly and enrich inbound only after mention detection, keeping strict @-required group policy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-23 14:36:14 +08:00
parent 538945fb66
commit 5336f8835f
4 changed files with 72 additions and 4 deletions

View file

@ -687,10 +687,14 @@ 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,
enrich_alert_group_question,
extract_quoted_ume_alert_text,
mentions_include_bot,
metadata_mentions_bot, metadata_mentions_bot,
resolve_group_policy, resolve_group_policy,
session_user_key, session_user_key,
should_process_group_inbound, should_process_group_inbound,
text_mentions_bot,
) )
group_policy = resolve_group_policy(account=account) group_policy = resolve_group_policy(account=account)
@ -844,8 +848,21 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
elif not reply: elif not reply:
user_text = (inbound.text or "").strip() user_text = (inbound.text or "").strip()
if inbound.is_group: if inbound.is_group:
meta_for_group = inbound.metadata if isinstance(inbound.metadata, dict) else {}
bot_reached = metadata_mentions_bot(meta_for_group) or mentions_include_bot(
mentions=list(inbound.mentions or []),
bot_jid=bot_jid,
metadata=meta_for_group,
) or text_mentions_bot(text=user_text, bot_jid=bot_jid)
if bot_reached:
quoted_alert = extract_quoted_ume_alert_text(metadata=meta_for_group)
if quoted_alert:
user_text = enrich_alert_group_question(
user_text=user_text,
quoted_alert=quoted_alert,
)
sender_ctx = build_group_sender_context( sender_ctx = build_group_sender_context(
metadata=inbound.metadata if isinstance(inbound.metadata, dict) else {}, metadata=meta_for_group,
external_user_id=inbound.external_user_id, external_user_id=inbound.external_user_id,
) )
user_text = f"{sender_ctx}\n{user_text}" if user_text else sender_ctx user_text = f"{sender_ctx}\n{user_text}" if user_text else sender_ctx

View file

@ -76,11 +76,14 @@ function unwrapMessage(m: proto.IMessage | null | undefined): proto.IMessage | n
function messageContextInfo(m: proto.IMessage | null | undefined): proto.IContextInfo | null | undefined { function messageContextInfo(m: proto.IMessage | null | undefined): proto.IContextInfo | null | undefined {
const u = unwrapMessage(m); const u = unwrapMessage(m);
if (!u) return null; if (!u) return null;
const extCtx = u.extendedTextMessage?.contextInfo;
// Reply+@ messages usually carry quote + mentions on extendedTextMessage.contextInfo.
if (extCtx && (extCtx.quotedMessage || extCtx.stanzaId)) return extCtx;
const top = (u as proto.IMessage & { messageContextInfo?: proto.IMessageContextInfo }).messageContextInfo; const top = (u as proto.IMessage & { messageContextInfo?: proto.IMessageContextInfo }).messageContextInfo;
const fromTop = top?.mentionedJid?.length ? top : null; const fromTop = top?.mentionedJid?.length ? top : null;
return ( return (
(fromTop as unknown as proto.IContextInfo) || (fromTop as unknown as proto.IContextInfo) ||
u.extendedTextMessage?.contextInfo || extCtx ||
u.imageMessage?.contextInfo || u.imageMessage?.contextInfo ||
u.videoMessage?.contextInfo || u.videoMessage?.contextInfo ||
u.documentMessage?.contextInfo || u.documentMessage?.contextInfo ||
@ -129,11 +132,23 @@ function extractMentions(m: proto.IMessage | null | undefined): string[] {
return raw.map((j) => 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 } { function pickQuotedText(m: proto.IMessage | null | undefined): string {
const ctx = messageContextInfo(m);
const qm = ctx?.quotedMessage;
if (!qm) return "";
return pickText(qm);
}
function extractQuoteContext(m: proto.IMessage | null | undefined): {
participant: string;
stanzaId: string;
quotedText: string;
} {
const ctx = messageContextInfo(m); const ctx = messageContextInfo(m);
return { return {
participant: String(ctx?.participant || "").trim(), participant: String(ctx?.participant || "").trim(),
stanzaId: String(ctx?.stanzaId || "").trim(), stanzaId: String(ctx?.stanzaId || "").trim(),
quotedText: pickQuotedText(m),
}; };
} }
@ -937,7 +952,6 @@ async function main(): Promise<void> {
const isReplyToBot = Boolean( const isReplyToBot = Boolean(
botJidRaw && botJidRaw &&
quote.participant && quote.participant &&
!mentions.length &&
botIdentityJids.some((botId) => mentionMatchesBotIdentity(quote.participant, botId)), botIdentityJids.some((botId) => mentionMatchesBotIdentity(quote.participant, botId)),
); );
@ -952,6 +966,7 @@ async function main(): Promise<void> {
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,
quotedText: quote.quotedText || null,
isReplyToBot, isReplyToBot,
mentionsBot, mentionsBot,
mentionedJids: mentions, mentionedJids: mentions,

View file

@ -146,6 +146,24 @@ def is_reply_to_bot(*, metadata: dict[str, Any] | None, bot_jid: str | None) ->
return False return False
def extract_quoted_ume_alert_text(*, metadata: dict[str, Any] | None) -> str:
raw = _metadata_raw(metadata)
quoted_text = str(raw.get("quotedText") or raw.get("quoted_text") or "").strip()
if quoted_text and (quoted_text.startswith("[UME") or "[UME Alarm" in quoted_text[:120]):
return quoted_text
return ""
def enrich_alert_group_question(*, user_text: str, quoted_alert: str) -> str:
body = str(user_text or "").strip()
quote = str(quoted_alert or "").strip()
if not quote:
return body
if not body:
return f"[Quoted UME alarm]\n{quote}"
return f"[Quoted UME alarm]\n{quote}\n\n[User question]\n{body}"
def normalize_jids(jids: list[str]) -> set[str]: def normalize_jids(jids: list[str]) -> set[str]:
out: set[str] = set() out: set[str] = set()
for raw in jids or []: for raw in jids or []:
@ -318,6 +336,8 @@ __all__ = [
"GroupPolicyConfig", "GroupPolicyConfig",
"build_group_sender_context", "build_group_sender_context",
"build_whatsapp_group_reply_metadata", "build_whatsapp_group_reply_metadata",
"enrich_alert_group_question",
"extract_quoted_ume_alert_text",
"mentions_include_bot", "mentions_include_bot",
"metadata_mentions_bot", "metadata_mentions_bot",
"normalize_jid", "normalize_jid",

View file

@ -221,6 +221,22 @@ def test_should_accept_group_trigger_without_mention() -> None:
) )
def test_enrich_quoted_ume_alarm_when_mentioned() -> None:
from runtime.orchestration.group_ingest import (
enrich_alert_group_question,
extract_quoted_ume_alert_text,
)
meta = {"raw": {"quotedText": "[UME Alarm Raised]\nDevice: NE1", "mentionsBot": True}}
assert extract_quoted_ume_alert_text(metadata=meta).startswith("[UME")
enriched = enrich_alert_group_question(
user_text="what happened?",
quoted_alert="[UME Alarm Raised]\nDevice: NE1",
)
assert "[Quoted UME alarm]" in enriched
assert "what happened?" in enriched
def test_session_user_key_group_sentinel() -> None: def test_session_user_key_group_sentinel() -> None:
assert session_user_key(is_group=True, external_user_id="111@s.whatsapp.net") == GROUP_SESSION_USER_SENTINEL assert session_user_key(is_group=True, external_user_id="111@s.whatsapp.net") == GROUP_SESSION_USER_SENTINEL
assert session_user_key(is_group=False, external_user_id="111@s.whatsapp.net") == "111@s.whatsapp.net" assert session_user_key(is_group=False, external_user_id="111@s.whatsapp.net") == "111@s.whatsapp.net"