mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-18 14:43:15 +08:00
Nudge WhatsApp group ops asks that skip @mention.
When require_mention drops an ops short-intent, send one throttled @sender tip instead of silent ignore; casual chat stays quiet. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
94a1b5b26c
commit
6c5536b758
4 changed files with 201 additions and 0 deletions
|
|
@ -1081,6 +1081,69 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
group_policy.require_mention,
|
group_policy.require_mention,
|
||||||
text[:120],
|
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": []}
|
return {"ok": True, "replies": []}
|
||||||
if str(inbound.channel or "").strip().lower() == "whatsapp":
|
if str(inbound.channel or "").strip().lower() == "whatsapp":
|
||||||
from runtime.application.gateway.whatsapp_inbound_access import handle_whatsapp_access
|
from runtime.application.gateway.whatsapp_inbound_access import handle_whatsapp_access
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
_BOT_MENTION_RE = re.compile(r"@\S+")
|
_BOT_MENTION_RE = re.compile(r"@\S+")
|
||||||
|
|
@ -108,9 +110,78 @@ def maybe_ops_short_intent_system_hint(*, text: str, lang: str = "en") -> str:
|
||||||
return build_ops_short_intent_hint(intent=intent, lang=lang)
|
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__ = [
|
__all__ = [
|
||||||
|
"build_group_mention_nudge_text",
|
||||||
"build_ops_short_intent_hint",
|
"build_ops_short_intent_hint",
|
||||||
"detect_ops_short_intent",
|
"detect_ops_short_intent",
|
||||||
"maybe_ops_short_intent_system_hint",
|
"maybe_ops_short_intent_system_hint",
|
||||||
"normalize_ops_user_text",
|
"normalize_ops_user_text",
|
||||||
|
"reset_group_mention_nudge_throttle_for_tests",
|
||||||
|
"should_send_group_mention_nudge",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -653,6 +653,47 @@ def test_inbound_group_without_mention_is_silent(monkeypatch: pytest.MonkeyPatch
|
||||||
)
|
)
|
||||||
assert out.get("ok") is True
|
assert out.get("ok") is True
|
||||||
assert out.get("replies") == []
|
assert out.get("replies") == []
|
||||||
|
assert out.get("delivery") != "mention_nudge"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inbound_group_ops_without_mention_gets_throttled_nudge(
|
||||||
|
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") == []
|
||||||
|
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
|
||||||
|
with store._connect() as conn: # noqa: SLF001
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT text, source 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") == []
|
||||||
|
|
||||||
|
|
||||||
def test_inbound_dm_still_processes_without_mention(monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore) -> None:
|
def test_inbound_dm_still_processes_without_mention(monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from runtime.application.gateway.ops_short_intent import (
|
from runtime.application.gateway.ops_short_intent import (
|
||||||
|
build_group_mention_nudge_text,
|
||||||
detect_ops_short_intent,
|
detect_ops_short_intent,
|
||||||
maybe_ops_short_intent_system_hint,
|
maybe_ops_short_intent_system_hint,
|
||||||
|
reset_group_mention_nudge_throttle_for_tests,
|
||||||
|
should_send_group_mention_nudge,
|
||||||
)
|
)
|
||||||
from runtime.tools.tool_error_hints import enrich_exec_managed_ne_error
|
from runtime.tools.tool_error_hints import enrich_exec_managed_ne_error
|
||||||
|
|
||||||
|
|
@ -17,6 +20,29 @@ 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
|
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_hint_english_default() -> None:
|
def test_ops_short_intent_hint_english_default() -> None:
|
||||||
hint = maybe_ops_short_intent_system_hint(text="LOS on these sites", lang="en")
|
hint = maybe_ops_short_intent_system_hint(text="LOS on these sites", lang="en")
|
||||||
assert "fiber" in hint.lower() or "LOS" in hint
|
assert "fiber" in hint.lower() or "LOS" in hint
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue