From 94a1b5b26cb03ec92da4482bdbd2a7bb09a162c6 Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 10 Aug 2026 23:37:50 +0800 Subject: [PATCH] Notify WhatsApp requesters on access decisions and ship EN ops help. After admin YES/NO, DM the requester a field guide; WA help/bind no longer returns the Chinese productivity todo menu. Co-authored-by: Cursor --- .../application/gateway/inbound_service.py | 23 +++++-- .../gateway/whatsapp_inbound_access.py | 67 ++++++++++++++++++- runtime/extensions/whatsapp/access_control.py | 37 ++++++++++ tests/test_whatsapp_inbound_access.py | 18 +++++ tests/test_whatsapp_ops_onboarding.py | 39 +++++++++++ 5 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 tests/test_whatsapp_ops_onboarding.py diff --git a/runtime/application/gateway/inbound_service.py b/runtime/application/gateway/inbound_service.py index 5175f61b..095e3199 100644 --- a/runtime/application/gateway/inbound_service.py +++ b/runtime/application/gateway/inbound_service.py @@ -100,7 +100,11 @@ def _build_admin_gateway_executor( ) -def _menu_text() -> str: +def _menu_text(*, channel: str = "") -> str: + if str(channel or "").strip().lower() == "whatsapp": + from runtime.extensions.whatsapp.access_control import whatsapp_ops_help_text + + return whatsapp_ops_help_text(lang="en") return ( "已绑定成功,常用命令:\n" "1) 帮助 / 菜单\n" @@ -121,13 +125,14 @@ def _handle_productivity_commands( session_id: str = "", creator_external_user_id: str = "", creator_push_name: str = "", + channel: str = "", ) -> str | None: t = (text or "").strip() t_low = t.lower() if not t: return None if t in ("帮助", "菜单", "help", "/help"): - return _menu_text() + return _menu_text(channel=channel) from svc.config.paths import db_path from svc.persistence.sqlite_store import SqliteStore @@ -1045,7 +1050,16 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: else None ), ) - reply = ("绑定成功。\n\n" + _menu_text()) if info else "绑定失败:无效或已使用的绑定码。" + channel_is_wa_bind = str(inbound.channel or "").strip().lower() == "whatsapp" + if info: + guide = _menu_text(channel=str(inbound.channel or "")) + reply = ("Bound successfully.\n\n" + guide) if channel_is_wa_bind else ("绑定成功。\n\n" + guide) + else: + reply = ( + "Bind failed: invalid or already used code." + if channel_is_wa_bind + else "绑定失败:无效或已使用的绑定码。" + ) else: if inbound.is_group and not should_process_group_inbound( is_group=inbound.is_group, @@ -1238,6 +1252,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: ) or "" ), + channel=str(inbound.channel or ""), ) if cmd_reply is not None: reply = cmd_reply @@ -1742,7 +1757,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: if reply: reply = f"{preface}\n\n{reply}" else: - reply = f"{preface}\n\n{_menu_text()}" + reply = f"{preface}\n\n{_menu_text(channel=str(inbound.channel or ''))}" ch_lower = str(inbound.channel or "").strip().lower() if ch_lower in {"wechat", "weixin"}: diff --git a/runtime/application/gateway/whatsapp_inbound_access.py b/runtime/application/gateway/whatsapp_inbound_access.py index d2ac8e81..bbe20e1b 100644 --- a/runtime/application/gateway/whatsapp_inbound_access.py +++ b/runtime/application/gateway/whatsapp_inbound_access.py @@ -5,6 +5,8 @@ import uuid from typing import Any from runtime.extensions.whatsapp.access_control import ( + access_denied_final_text, + access_granted_guide_text, admin_approval_result_text, admin_notify_text, default_access_lang, @@ -24,7 +26,7 @@ from runtime.extensions.whatsapp.access_control import ( resolve_whatsapp_sender_jid, whatsapp_sender_lookup_jids, ) -from runtime.extensions.whatsapp.api import is_whatsapp_user_target +from runtime.extensions.whatsapp.api import is_whatsapp_user_target, normalize_whatsapp_target from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id @@ -167,6 +169,22 @@ def _ensure_guest_identity( ) +def _requester_dm_chat_id(*, external_user_id: str, phone: str = "") -> str: + jid = str(external_user_id or "").strip() + if jid and is_whatsapp_user_target(jid): + try: + return normalize_whatsapp_target(jid) + except Exception: + return jid + ph = str(phone or "").strip() or phone_from_jid(jid) + if ph: + try: + return normalize_whatsapp_target(ph) + except Exception: + pass + return jid + + def _notify_admins( store: Any, *, @@ -210,6 +228,42 @@ def _notify_admins( ) +def _notify_requester_access_decision( + store: Any, + *, + tenant_id: str, + account_id: str, + lang: str, + external_user_id: str, + phone: str, + pending_id: str, + approved: bool, +) -> None: + chat_id = _requester_dm_chat_id(external_user_id=external_user_id, phone=phone) + if not chat_id or not is_whatsapp_user_target(chat_id): + return + text = ( + access_granted_guide_text(lang=lang) + if approved + else access_denied_final_text(lang=lang) + ) + store.enqueue_channel_outbound_message( + channel="whatsapp", + chat_id=chat_id, + text=text, + tenant_id=tenant_id, + account_id=account_id, + source=json.dumps( + { + "kind": "whatsapp_access_decision", + "pending_id": str(pending_id or ""), + "approved": bool(approved), + }, + ensure_ascii=False, + ), + ) + + def _upsert_whatsapp_contact_profile( store: Any, *, @@ -347,6 +401,17 @@ def _handle_admin_message( if callable(deleter): deleter(pending_id=pending_id) + _notify_requester_access_decision( + store, + tenant_id=tenant_id, + account_id=account_id, + lang=lang, + external_user_id=target_jid, + phone=target_phone, + pending_id=pending_id, + approved=approved, + ) + raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {} reply_meta: dict[str, Any] = { "quote_remote_jid": admin_chat_id, diff --git a/runtime/extensions/whatsapp/access_control.py b/runtime/extensions/whatsapp/access_control.py index 068ccd64..41188403 100644 --- a/runtime/extensions/whatsapp/access_control.py +++ b/runtime/extensions/whatsapp/access_control.py @@ -343,6 +343,43 @@ def coerce_whatsapp_access_target(value: str) -> str: return normalize_whatsapp_target(normalize_whatsapp_phone(value)) +def whatsapp_ops_help_text(*, lang: str = "en") -> str: + """Short field-ops guide for WhatsApp (English-first deployments).""" + if str(lang or "").strip().lower().startswith("zh"): + return ( + "群聊请 @机器人 再提问。\n" + "常用示例:\n" + "- 断纤 / fiber cut 告警\n" + "- 离线网元清单\n" + "- 告警导出 Excel / alarm excel\n" + "- license / capacity 查询\n" + "长任务可回复 YES / continue。\n" + "绑定团队账号:bind " + ) + return ( + "In groups, @mention the bot first.\n" + "Try:\n" + "- fiber cut alarms\n" + "- list offline NEs\n" + "- alarm excel report\n" + "- license / capacity check\n" + "During long tasks reply YES / continue.\n" + "Team account: bind " + ) + + +def access_granted_guide_text(*, lang: str = "en") -> str: + if str(lang or "").strip().lower().startswith("zh"): + return "访问已批准,可以使用助手了。\n\n" + whatsapp_ops_help_text(lang=lang) + return "Access approved — you can use the assistant now.\n\n" + whatsapp_ops_help_text(lang=lang) + + +def access_denied_final_text(*, lang: str = "en") -> str: + if str(lang or "").strip().lower().startswith("zh"): + return "访问申请未通过。如需使用请联系管理员。" + return "Your access request was not approved. Contact an administrator if you still need access." + + def denied_reply_text(*, lang: str, pending_id: str = "", already_pending: bool = False) -> str: pid = str(pending_id or "").strip() if str(lang or "").strip().lower().startswith("zh"): diff --git a/tests/test_whatsapp_inbound_access.py b/tests/test_whatsapp_inbound_access.py index 00f59323..0a1eae2e 100644 --- a/tests/test_whatsapp_inbound_access.py +++ b/tests/test_whatsapp_inbound_access.py @@ -272,6 +272,17 @@ def test_handle_whatsapp_access_admin_yes_with_stanza_mapping(monkeypatch) -> No replies = out.get("replies") if isinstance(out.get("replies"), list) else [] md = replies[0].get("metadata") if replies and isinstance(replies[0], dict) else {} assert md.get("quote_stanza_id") == "admin_reply_1" + decision = [ + o + for o in store.outbound + if "whatsapp_access_decision" in str(o.get("source") or "") + ] + assert len(decision) == 1 + assert "8615601877957" in str(decision[0].get("chat_id") or "") + assert "Access approved" in str(decision[0].get("text") or "") + assert "@mention" in str(decision[0].get("text") or "").lower() or "fiber" in str( + decision[0].get("text") or "" + ).lower() def test_handle_whatsapp_access_admin_yes_with_quoted_text_fallback(monkeypatch) -> None: @@ -305,6 +316,13 @@ def test_handle_whatsapp_access_admin_yes_with_quoted_text_fallback(monkeypatch) assert out is not None assert store.pending[0]["status"] == "denied" assert store.contacts.get("8615601877957", {}).get("list_type") == "blacklist" + decision = [ + o + for o in store.outbound + if "whatsapp_access_decision" in str(o.get("source") or "") + ] + assert len(decision) == 1 + assert "not approved" in str(decision[0].get("text") or "").lower() def test_handle_whatsapp_access_denied_enqueues_notify_with_pending_source(monkeypatch) -> None: diff --git a/tests/test_whatsapp_ops_onboarding.py b/tests/test_whatsapp_ops_onboarding.py new file mode 100644 index 00000000..2c0b03cc --- /dev/null +++ b/tests/test_whatsapp_ops_onboarding.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from runtime.application.gateway.inbound_service import _handle_productivity_commands, _menu_text +from runtime.extensions.whatsapp.access_control import ( + access_granted_guide_text, + whatsapp_ops_help_text, +) + + +def test_whatsapp_ops_help_is_english_field_guide() -> None: + text = whatsapp_ops_help_text(lang="en") + assert "fiber" in text.lower() or "alarm" in text.lower() + assert "@mention" in text.lower() or "mention" in text.lower() + assert "记待办" not in text + + +def test_access_granted_guide_includes_ops_help() -> None: + text = access_granted_guide_text(lang="en") + assert "Access approved" in text + assert "YES" in text or "continue" in text.lower() + + +def test_menu_text_whatsapp_not_productivity_chinese() -> None: + text = _menu_text(channel="whatsapp") + assert "记待办" not in text + assert "fiber" in text.lower() or "alarm" in text.lower() + assert "记待办" in _menu_text(channel="weixin") + + +def test_productivity_help_on_whatsapp_returns_ops_guide() -> None: + out = _handle_productivity_commands( + text="help", + tenant_id="t1", + user_id="u1", + channel="whatsapp", + ) + assert out is not None + assert "记待办" not in out + assert "fiber" in out.lower() or "offline" in out.lower() or "alarm" in out.lower()