mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
parent
f31ebeb30e
commit
94a1b5b26c
5 changed files with 179 additions and 5 deletions
|
|
@ -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 (
|
return (
|
||||||
"已绑定成功,常用命令:\n"
|
"已绑定成功,常用命令:\n"
|
||||||
"1) 帮助 / 菜单\n"
|
"1) 帮助 / 菜单\n"
|
||||||
|
|
@ -121,13 +125,14 @@ def _handle_productivity_commands(
|
||||||
session_id: str = "",
|
session_id: str = "",
|
||||||
creator_external_user_id: str = "",
|
creator_external_user_id: str = "",
|
||||||
creator_push_name: str = "",
|
creator_push_name: str = "",
|
||||||
|
channel: str = "",
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
t = (text or "").strip()
|
t = (text or "").strip()
|
||||||
t_low = t.lower()
|
t_low = t.lower()
|
||||||
if not t:
|
if not t:
|
||||||
return None
|
return None
|
||||||
if t in ("帮助", "菜单", "help", "/help"):
|
if t in ("帮助", "菜单", "help", "/help"):
|
||||||
return _menu_text()
|
return _menu_text(channel=channel)
|
||||||
|
|
||||||
from svc.config.paths import db_path
|
from svc.config.paths import db_path
|
||||||
from svc.persistence.sqlite_store import SqliteStore
|
from svc.persistence.sqlite_store import SqliteStore
|
||||||
|
|
@ -1045,7 +1050,16 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
else None
|
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:
|
else:
|
||||||
if inbound.is_group and not should_process_group_inbound(
|
if inbound.is_group and not should_process_group_inbound(
|
||||||
is_group=inbound.is_group,
|
is_group=inbound.is_group,
|
||||||
|
|
@ -1238,6 +1252,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
)
|
)
|
||||||
or ""
|
or ""
|
||||||
),
|
),
|
||||||
|
channel=str(inbound.channel or ""),
|
||||||
)
|
)
|
||||||
if cmd_reply is not None:
|
if cmd_reply is not None:
|
||||||
reply = cmd_reply
|
reply = cmd_reply
|
||||||
|
|
@ -1742,7 +1757,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
if reply:
|
if reply:
|
||||||
reply = f"{preface}\n\n{reply}"
|
reply = f"{preface}\n\n{reply}"
|
||||||
else:
|
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()
|
ch_lower = str(inbound.channel or "").strip().lower()
|
||||||
if ch_lower in {"wechat", "weixin"}:
|
if ch_lower in {"wechat", "weixin"}:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from runtime.extensions.whatsapp.access_control import (
|
from runtime.extensions.whatsapp.access_control import (
|
||||||
|
access_denied_final_text,
|
||||||
|
access_granted_guide_text,
|
||||||
admin_approval_result_text,
|
admin_approval_result_text,
|
||||||
admin_notify_text,
|
admin_notify_text,
|
||||||
default_access_lang,
|
default_access_lang,
|
||||||
|
|
@ -24,7 +26,7 @@ from runtime.extensions.whatsapp.access_control import (
|
||||||
resolve_whatsapp_sender_jid,
|
resolve_whatsapp_sender_jid,
|
||||||
whatsapp_sender_lookup_jids,
|
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
|
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(
|
def _notify_admins(
|
||||||
store: Any,
|
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(
|
def _upsert_whatsapp_contact_profile(
|
||||||
store: Any,
|
store: Any,
|
||||||
*,
|
*,
|
||||||
|
|
@ -347,6 +401,17 @@ def _handle_admin_message(
|
||||||
if callable(deleter):
|
if callable(deleter):
|
||||||
deleter(pending_id=pending_id)
|
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 {}
|
raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
|
||||||
reply_meta: dict[str, Any] = {
|
reply_meta: dict[str, Any] = {
|
||||||
"quote_remote_jid": admin_chat_id,
|
"quote_remote_jid": admin_chat_id,
|
||||||
|
|
|
||||||
|
|
@ -343,6 +343,43 @@ def coerce_whatsapp_access_target(value: str) -> str:
|
||||||
return normalize_whatsapp_target(normalize_whatsapp_phone(value))
|
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 <code>"
|
||||||
|
)
|
||||||
|
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 <code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def denied_reply_text(*, lang: str, pending_id: str = "", already_pending: bool = False) -> str:
|
||||||
pid = str(pending_id or "").strip()
|
pid = str(pending_id or "").strip()
|
||||||
if str(lang or "").strip().lower().startswith("zh"):
|
if str(lang or "").strip().lower().startswith("zh"):
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
replies = out.get("replies") if isinstance(out.get("replies"), list) else []
|
||||||
md = replies[0].get("metadata") if replies and isinstance(replies[0], dict) else {}
|
md = replies[0].get("metadata") if replies and isinstance(replies[0], dict) else {}
|
||||||
assert md.get("quote_stanza_id") == "admin_reply_1"
|
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:
|
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 out is not None
|
||||||
assert store.pending[0]["status"] == "denied"
|
assert store.pending[0]["status"] == "denied"
|
||||||
assert store.contacts.get("8615601877957", {}).get("list_type") == "blacklist"
|
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:
|
def test_handle_whatsapp_access_denied_enqueues_notify_with_pending_source(monkeypatch) -> None:
|
||||||
|
|
|
||||||
39
tests/test_whatsapp_ops_onboarding.py
Normal file
39
tests/test_whatsapp_ops_onboarding.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue