Tighten WhatsApp group gates: stay silent without @ and ignore quote-only replies.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-11 11:28:26 +08:00
parent 76f62d8fac
commit 8419df2187
5 changed files with 40 additions and 191 deletions

View file

@ -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

View file

@ -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",
]

View file

@ -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

View file

@ -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,20 +667,17 @@ 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 = {
out = process_inbound_payload(
{
"channel": "whatsapp",
"account_id": "wa-default",
"user_id": "111@s.whatsapp.net",
@ -676,24 +687,21 @@ def test_inbound_group_ops_without_mention_gets_throttled_nudge(
"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") == []
)
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:

View file

@ -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")