diff --git a/runtime/application/gateway/inbound_service.py b/runtime/application/gateway/inbound_service.py index 0ee7393f..34c4353a 100644 --- a/runtime/application/gateway/inbound_service.py +++ b/runtime/application/gateway/inbound_service.py @@ -1164,69 +1164,6 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: group_policy.require_mention, text[:120], ) - # Ops short-intents without @bot look "broken" in field groups — nudge once (throttled). - if str(inbound.channel or "").strip().lower() == "whatsapp" and bool( - group_policy.require_mention - ): - try: - from runtime.application.gateway.ops_short_intent import ( - build_group_mention_nudge_text, - detect_ops_short_intent, - should_send_group_mention_nudge, - ) - from runtime.application.gateway.whatsapp_progress import ( - build_whatsapp_group_progress_metadata, - ) - from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id - - intent = detect_ops_short_intent(text) - if intent and should_send_group_mention_nudge( - account_id=account_id, - chat_id=str(inbound.external_chat_id or ""), - user_id=str(inbound.external_user_id or ""), - ): - nudge_lang = "en" - try: - cfg = store.get_whatsapp_access_config( - tenant_id=resolve_whatsapp_tenant_id(store, account_id=account_id), - account_id=account_id, - ) - nudge_lang = str((cfg or {}).get("lang") or "en") - except Exception: - nudge_lang = "en" - nudge_text = build_group_mention_nudge_text( - intent=intent, - lang=nudge_lang, - triggers=list(group_policy.triggers or []), - ) - nudge_meta = build_whatsapp_group_progress_metadata(inbound=inbound) - tenant_for_nudge = "" - try: - tenant_for_nudge = str( - resolve_whatsapp_tenant_id(store, account_id=account_id) or "" - ) - except Exception: - tenant_for_nudge = "" - _enqueue_whatsapp_inbound_reply( - store, - inbound=inbound, - account_id=account_id, - tenant_id=tenant_for_nudge, - reply_text=nudge_text, - reply_attachments=None, - reply_metadata=nudge_meta, - kind="inbound_progress", - ) - return { - "ok": True, - "replies": [], - "delivery": "mention_nudge", - "ops_intent": intent, - } - except Exception: - logging.getLogger(__name__).debug( - "whatsapp group mention nudge failed", exc_info=True - ) return {"ok": True, "replies": []} if str(inbound.channel or "").strip().lower() == "whatsapp": from runtime.application.gateway.whatsapp_inbound_access import handle_whatsapp_access diff --git a/runtime/application/gateway/ops_short_intent.py b/runtime/application/gateway/ops_short_intent.py index 3c228319..dd387a78 100644 --- a/runtime/application/gateway/ops_short_intent.py +++ b/runtime/application/gateway/ops_short_intent.py @@ -3,8 +3,6 @@ from __future__ import annotations import re -import threading -import time from typing import Any _BOT_MENTION_RE = re.compile(r"@\S+") @@ -181,74 +179,7 @@ def maybe_ops_short_intent_system_hint(*, text: str, lang: str = "en") -> str: return build_ops_short_intent_hint(intent=intent, lang=lang) -def build_group_mention_nudge_text( - *, - intent: str | None = None, - lang: str = "en", - triggers: list[str] | None = None, -) -> str: - """Tell field users why a group ops ask was ignored (require @bot / trigger).""" - extra = "" - trigs = [str(x or "").strip() for x in (triggers or []) if str(x or "").strip()] - if trigs: - shown = ", ".join(trigs[:3]) - if str(lang or "").strip().lower().startswith("zh"): - extra = f"或发送触发词({shown})" - else: - extra = f" or use a trigger ({shown})" - label = str(intent or "").strip().replace("_", " ") - if str(lang or "").strip().lower().startswith("zh"): - topic = f"(识别到:{label})" if label else "" - return ( - f"群里需要先 @我{extra} 才会处理运维请求{topic}。" - "请带上 @ 后重发(断纤/离线/告警/Excel/license 等)。" - ) - topic = f" (detected: {label})" if label else "" - return ( - f"In this group I only answer when @mentioned{extra}{topic}. " - "Please re-send with @me for ops asks (fiber / offline / alarms / excel / license)." - ) - - -_MENTION_NUDGE_LOCK = threading.Lock() -_MENTION_NUDGE_LAST: dict[str, float] = {} -_MENTION_NUDGE_TTL_S = 12 * 60.0 - - -def should_send_group_mention_nudge( - *, - account_id: str, - chat_id: str, - user_id: str, - now: float | None = None, - ttl_s: float | None = None, -) -> bool: - """Throttle one nudge per sender/chat for a few minutes.""" - key = f"{str(account_id or '').strip()}|{str(chat_id or '').strip()}|{str(user_id or '').strip()}" - if not key.strip("|"): - return False - ts = float(now if now is not None else time.monotonic()) - window = float(ttl_s if ttl_s is not None else _MENTION_NUDGE_TTL_S) - with _MENTION_NUDGE_LOCK: - prev = _MENTION_NUDGE_LAST.get(key) - if prev is not None and (ts - float(prev)) < window: - return False - _MENTION_NUDGE_LAST[key] = ts - if len(_MENTION_NUDGE_LAST) > 512: - cutoff = ts - window - stale = [k for k, v in _MENTION_NUDGE_LAST.items() if float(v) < cutoff] - for k in stale[:128]: - _MENTION_NUDGE_LAST.pop(k, None) - return True - - -def reset_group_mention_nudge_throttle_for_tests() -> None: - with _MENTION_NUDGE_LOCK: - _MENTION_NUDGE_LAST.clear() - - __all__ = [ - "build_group_mention_nudge_text", "build_ops_short_intent_hint", "detect_ops_short_intent", "filter_tool_specs_for_ops_short_intent", @@ -256,6 +187,4 @@ __all__ = [ "maybe_ops_short_intent_system_hint", "normalize_ops_user_text", "ops_short_intent_should_filter_tools", - "reset_group_mention_nudge_throttle_for_tests", - "should_send_group_mention_nudge", ] diff --git a/runtime/orchestration/group_ingest.py b/runtime/orchestration/group_ingest.py index f39b694e..7bef6142 100644 --- a/runtime/orchestration/group_ingest.py +++ b/runtime/orchestration/group_ingest.py @@ -406,9 +406,10 @@ def should_process_group_inbound( return True return has_trigger + # Quote/reply-to-bot alone is not enough — require explicit @ or a trigger. if metadata_mentions_bot(metadata): return True - if is_reply_to_bot(metadata=metadata, bot_jid=bot_jid): + if bot_mentioned: return True if has_trigger: return True diff --git a/tests/test_group_ingest.py b/tests/test_group_ingest.py index 7bfcfd38..ff37c191 100644 --- a/tests/test_group_ingest.py +++ b/tests/test_group_ingest.py @@ -196,7 +196,7 @@ def test_should_reject_group_when_other_mentioned_even_if_reply_to_bot() -> None ) -def test_should_accept_group_reply_to_bot() -> None: +def test_should_reject_group_reply_to_bot_without_mention() -> None: assert ( should_process_group_inbound( is_group=True, @@ -206,6 +206,20 @@ def test_should_accept_group_reply_to_bot() -> None: require_mention=True, metadata={"raw": {"quotedParticipant": "999:0@s.whatsapp.net", "isReplyToBot": True}}, ) + is False + ) + + +def test_should_accept_group_reply_to_bot_when_also_mentioned() -> None: + assert ( + should_process_group_inbound( + is_group=True, + text="@bot follow up", + mentions=["999:0@s.whatsapp.net"], + bot_jid="999@s.whatsapp.net", + require_mention=True, + metadata={"raw": {"quotedParticipant": "999:0@s.whatsapp.net", "isReplyToBot": True}}, + ) is True ) @@ -653,47 +667,41 @@ def test_inbound_group_without_mention_is_silent(monkeypatch: pytest.MonkeyPatch ) assert out.get("ok") is True assert out.get("replies") == [] - assert out.get("delivery") != "mention_nudge" -def test_inbound_group_ops_without_mention_gets_throttled_nudge( +def test_inbound_group_ops_without_mention_stays_silent( monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore ) -> None: - from runtime.application.gateway.ops_short_intent import reset_group_mention_nudge_throttle_for_tests - store = fresh_sqlite_store _setup_whatsapp_identity(store) monkeypatch.setattr("svc.persistence.assistant_store.get_assistant_store", lambda: store) - reset_group_mention_nudge_throttle_for_tests() - payload = { - "channel": "whatsapp", - "account_id": "wa-default", - "user_id": "111@s.whatsapp.net", - "chat_id": "120363012345678@g.us", - "text": "fiber cut sites", - "is_group": True, - "mentions": [], - "metadata": {"bot_jid": "999@s.whatsapp.net", "source": "test"}, - } - first = process_inbound_payload(payload) - assert first.get("delivery") == "mention_nudge" - assert first.get("ops_intent") == "fiber_cut" - assert first.get("replies") == [] + out = process_inbound_payload( + { + "channel": "whatsapp", + "account_id": "wa-default", + "user_id": "111@s.whatsapp.net", + "chat_id": "120363012345678@g.us", + "text": "fiber cut sites", + "is_group": True, + "mentions": [], + "metadata": {"bot_jid": "999@s.whatsapp.net", "source": "test"}, + } + ) + assert out.get("ok") is True + assert out.get("replies") == [] + assert out.get("delivery") != "mention_nudge" outbound = store.list_channel_outbound_messages(limit=20) if hasattr(store, "list_channel_outbound_messages") else [] - if not outbound: - # Fallback: inspect via raw SQL if list helper missing + if not outbound and hasattr(store, "_connect"): with store._connect() as conn: # noqa: SLF001 rows = conn.execute( - "SELECT text, source FROM channel_outbound_message ORDER BY created_at DESC LIMIT 5" + "SELECT text FROM channel_outbound_message ORDER BY created_at DESC LIMIT 5" ).fetchall() - outbound = [{"text": r[0], "source": r[1]} for r in rows] - assert outbound - assert any("@mention" in str(o.get("text") or "").lower() or "@me" in str(o.get("text") or "").lower() for o in outbound) - - second = process_inbound_payload(payload) - assert second.get("delivery") != "mention_nudge" - assert second.get("replies") == [] + outbound = [{"text": r[0]} for r in rows] + assert not any( + "@mentioned" in str(o.get("text") or "").lower() or "re-send with @me" in str(o.get("text") or "").lower() + for o in (outbound or []) + ) def test_inbound_dm_still_processes_without_mention(monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore) -> None: diff --git a/tests/test_ops_short_intent_and_exec_hints.py b/tests/test_ops_short_intent_and_exec_hints.py index 5460b5a1..f1897326 100644 --- a/tests/test_ops_short_intent_and_exec_hints.py +++ b/tests/test_ops_short_intent_and_exec_hints.py @@ -1,13 +1,10 @@ from __future__ import annotations from runtime.application.gateway.ops_short_intent import ( - build_group_mention_nudge_text, detect_ops_short_intent, filter_tool_specs_for_ops_short_intent, is_ops_short_intent_suppressed_tool, maybe_ops_short_intent_system_hint, - reset_group_mention_nudge_throttle_for_tests, - should_send_group_mention_nudge, ) from runtime.tools.base import ToolSpec from runtime.tools.tool_error_hints import enrich_exec_managed_ne_error, enrich_get_managed_ne_error @@ -23,29 +20,6 @@ def test_detect_ops_short_intent_english_field() -> None: assert detect_ops_short_intent("hello there how are you doing today with something else") is None -def test_group_mention_nudge_text_english() -> None: - text = build_group_mention_nudge_text(intent="fiber_cut", lang="en", triggers=["/oclaw"]) - assert "@mentioned" in text.lower() or "@me" in text.lower() - assert "/oclaw" in text - assert "fiber" in text.lower() - - -def test_group_mention_nudge_throttle() -> None: - reset_group_mention_nudge_throttle_for_tests() - assert should_send_group_mention_nudge( - account_id="wa", chat_id="g1", user_id="u1", now=100.0, ttl_s=60.0 - ) - assert not should_send_group_mention_nudge( - account_id="wa", chat_id="g1", user_id="u1", now=130.0, ttl_s=60.0 - ) - assert should_send_group_mention_nudge( - account_id="wa", chat_id="g1", user_id="u2", now=130.0, ttl_s=60.0 - ) - assert should_send_group_mention_nudge( - account_id="wa", chat_id="g1", user_id="u1", now=170.0, ttl_s=60.0 - ) - - def test_ops_short_intent_suppresses_inventory_cli_tools() -> None: assert is_ops_short_intent_suppressed_tool("mcp__netx__listCliTargets", intent="fiber_cut") assert is_ops_short_intent_suppressed_tool("mcp__netx__execManagedNe", intent="offline")