mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 07:50:45 +08:00
feat(whatsapp): add access control with quote-based admin approval
Introduce whitelist/blacklist gating, pending requests, Admin UI management, and stanza-mapped quote approval so admins can approve via WhatsApp DM without blocking normal LLM chat. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8781a200c9
commit
1ea13a205d
13 changed files with 3048 additions and 30 deletions
|
|
@ -58,7 +58,7 @@ def _resolve_channel_dispatch(store: Any, *, channel: str, account: dict[str, An
|
|||
_get_channel_dispatch_setting(store, _CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX, ch) or "generalist"
|
||||
)
|
||||
lang = _normalize_channel_dispatch_lang(
|
||||
_get_channel_dispatch_setting(store, _CHANNEL_DISPATCH_LANG_KEY_PREFIX, ch) or "auto"
|
||||
_get_channel_dispatch_setting(store, _CHANNEL_DISPATCH_LANG_KEY_PREFIX, ch) or ("en" if ch == "whatsapp" else "auto")
|
||||
)
|
||||
cfg = (account or {}).get("config")
|
||||
if isinstance(cfg, dict):
|
||||
|
|
@ -820,6 +820,17 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|||
text[:120],
|
||||
)
|
||||
return {"ok": True, "replies": []}
|
||||
if str(inbound.channel or "").strip().lower() == "whatsapp":
|
||||
from runtime.application.gateway.whatsapp_inbound_access import handle_whatsapp_access
|
||||
|
||||
access_out = handle_whatsapp_access(
|
||||
store,
|
||||
inbound=inbound,
|
||||
account_id=account_id,
|
||||
text=text,
|
||||
)
|
||||
if access_out is not None:
|
||||
return access_out
|
||||
reply = ""
|
||||
reply_attachments: list[dict[str, Any]] = []
|
||||
ident = store.resolve_user_by_channel_identity_v2(
|
||||
|
|
@ -829,7 +840,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|||
)
|
||||
if not ident:
|
||||
owner = _ensure_administrator_owner(store)
|
||||
if owner:
|
||||
if owner and str(inbound.channel or "").strip().lower() != "whatsapp":
|
||||
store.upsert_user_channel_account(
|
||||
tenant_id=str(owner.get("tenant_id") or ""),
|
||||
user_id=str(owner.get("user_id") or ""),
|
||||
|
|
|
|||
512
runtime/application/gateway/whatsapp_inbound_access.py
Normal file
512
runtime/application/gateway/whatsapp_inbound_access.py
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from runtime.extensions.whatsapp.access_control import (
|
||||
admin_approval_result_text,
|
||||
admin_notify_text,
|
||||
default_access_lang,
|
||||
default_access_mode,
|
||||
denied_reply_text,
|
||||
extract_participant_alt,
|
||||
extract_push_name,
|
||||
extract_quote_context,
|
||||
extract_remote_jid_alt,
|
||||
is_access_allowed,
|
||||
normalize_whatsapp_phone,
|
||||
parse_admin_access_command,
|
||||
parse_admin_approval_intent,
|
||||
parse_pending_id_from_notify_text,
|
||||
phone_from_jid,
|
||||
resolve_sender_phone,
|
||||
resolve_whatsapp_sender_jid,
|
||||
whatsapp_sender_lookup_jids,
|
||||
)
|
||||
from runtime.extensions.whatsapp.api import is_whatsapp_user_target
|
||||
from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id
|
||||
|
||||
|
||||
def _extract_contact_fields(inbound: Any) -> tuple[str, str, str, str]:
|
||||
meta = inbound.metadata if isinstance(inbound.metadata, dict) else {}
|
||||
push_name = extract_push_name(meta)
|
||||
raw_jid = str(inbound.external_user_id or "").strip()
|
||||
participant_alt = extract_participant_alt(meta)
|
||||
remote_jid_alt = extract_remote_jid_alt(meta)
|
||||
canonical_jid = resolve_whatsapp_sender_jid(raw_jid, meta)
|
||||
phone = resolve_sender_phone(raw_jid, participant_alt, remote_jid_alt)
|
||||
return push_name, phone, raw_jid, canonical_jid
|
||||
|
||||
|
||||
def _link_identity_aliases(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
user_id: str,
|
||||
jids: tuple[str, ...],
|
||||
) -> None:
|
||||
for jid in jids:
|
||||
val = str(jid or "").strip()
|
||||
if not val:
|
||||
continue
|
||||
store.upsert_channel_identity_v2(
|
||||
tenant_id=tenant_id,
|
||||
channel="whatsapp",
|
||||
account_id=account_id,
|
||||
external_user_id=val,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_admin_identity(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
external_user_id: str,
|
||||
participant_alt: str,
|
||||
push_name: str,
|
||||
phone: str,
|
||||
) -> dict[str, Any] | None:
|
||||
lookup_jids = whatsapp_sender_lookup_jids(external_user_id, participant_alt)
|
||||
for jid in lookup_jids:
|
||||
ident = store.resolve_user_by_channel_identity_v2(
|
||||
channel="whatsapp",
|
||||
account_id=account_id,
|
||||
external_user_id=jid,
|
||||
)
|
||||
if ident:
|
||||
_link_identity_aliases(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
user_id=str(ident.get("user_id") or ""),
|
||||
jids=lookup_jids,
|
||||
)
|
||||
return ident
|
||||
|
||||
admin_user = store.get_user_by_username(tenant_id=tenant_id, username="administrator")
|
||||
user_id = str((admin_user or {}).get("id") or "")
|
||||
if not user_id:
|
||||
return _ensure_guest_identity(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
external_user_id=external_user_id,
|
||||
participant_alt=participant_alt,
|
||||
push_name=push_name,
|
||||
phone=phone,
|
||||
)
|
||||
_link_identity_aliases(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
user_id=user_id,
|
||||
jids=lookup_jids,
|
||||
)
|
||||
return store.resolve_user_by_channel_identity_v2(
|
||||
channel="whatsapp",
|
||||
account_id=account_id,
|
||||
external_user_id=lookup_jids[0] if lookup_jids else external_user_id,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_guest_identity(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
external_user_id: str,
|
||||
participant_alt: str,
|
||||
push_name: str,
|
||||
phone: str,
|
||||
) -> dict[str, Any] | None:
|
||||
lookup_jids = whatsapp_sender_lookup_jids(external_user_id, participant_alt)
|
||||
for jid in lookup_jids:
|
||||
ident = store.resolve_user_by_channel_identity_v2(
|
||||
channel="whatsapp",
|
||||
account_id=account_id,
|
||||
external_user_id=jid,
|
||||
)
|
||||
if ident:
|
||||
_link_identity_aliases(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
user_id=str(ident.get("user_id") or ""),
|
||||
jids=lookup_jids,
|
||||
)
|
||||
return ident
|
||||
|
||||
label = str(push_name or "").strip() or phone or external_user_id
|
||||
username = f"wa_{phone or uuid.uuid4().hex[:10]}"
|
||||
user = store.create_user_account(
|
||||
tenant_id=tenant_id,
|
||||
username=username,
|
||||
display_name=label,
|
||||
role="guest",
|
||||
password_hash="",
|
||||
is_active=True,
|
||||
)
|
||||
user_id = str((user or {}).get("id") or "")
|
||||
if not user_id:
|
||||
return None
|
||||
_link_identity_aliases(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
user_id=user_id,
|
||||
jids=lookup_jids,
|
||||
)
|
||||
return store.resolve_user_by_channel_identity_v2(
|
||||
channel="whatsapp",
|
||||
account_id=account_id,
|
||||
external_user_id=lookup_jids[0] if lookup_jids else external_user_id,
|
||||
)
|
||||
|
||||
|
||||
def _notify_admins(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
lang: str,
|
||||
push_name: str,
|
||||
external_user_id: str,
|
||||
request_text: str,
|
||||
pending_id: str,
|
||||
) -> None:
|
||||
admins = store.list_whatsapp_contacts(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
list_type="admin",
|
||||
)
|
||||
text = admin_notify_text(
|
||||
lang=lang,
|
||||
push_name=push_name,
|
||||
external_user_id=external_user_id,
|
||||
request_text=request_text,
|
||||
pending_id=pending_id,
|
||||
)
|
||||
for admin in admins:
|
||||
chat_id = str(admin.get("external_user_id") or "").strip()
|
||||
if not chat_id or not is_whatsapp_user_target(chat_id):
|
||||
continue
|
||||
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_pending",
|
||||
"pending_id": str(pending_id or ""),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _upsert_whatsapp_contact_profile(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
raw_jid: str,
|
||||
canonical_jid: str,
|
||||
push_name: str,
|
||||
phone: str,
|
||||
) -> None:
|
||||
store.upsert_whatsapp_contact(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
external_user_id=raw_jid,
|
||||
push_name=push_name,
|
||||
phone=phone,
|
||||
)
|
||||
if canonical_jid and canonical_jid != raw_jid:
|
||||
store.upsert_whatsapp_contact(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
external_user_id=canonical_jid,
|
||||
push_name=push_name,
|
||||
phone=phone_from_jid(canonical_jid),
|
||||
)
|
||||
|
||||
|
||||
def _handle_admin_message(
|
||||
store: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
external_user_id: str,
|
||||
text: str,
|
||||
lang: str,
|
||||
inbound: Any,
|
||||
) -> dict[str, Any] | None:
|
||||
cmd = parse_admin_access_command(text)
|
||||
if cmd:
|
||||
action = str(cmd.get("action") or "")
|
||||
if action == "set_mode":
|
||||
mode = str(cmd.get("access_mode") or default_access_mode())
|
||||
store.upsert_whatsapp_access_config(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
access_mode=mode,
|
||||
lang=lang,
|
||||
)
|
||||
return {"text": f"Access mode set to {mode}.", "metadata": {}}
|
||||
if action == "set_list":
|
||||
list_type = str(cmd.get("list_type") or "")
|
||||
target_raw = str(cmd.get("target") or "").strip()
|
||||
try:
|
||||
target_phone = normalize_whatsapp_phone(target_raw)
|
||||
except Exception:
|
||||
return {"text": f"Invalid target: {target_raw}", "metadata": {}}
|
||||
store.apply_whatsapp_contact_access(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
external_user_id=target_phone,
|
||||
phone=target_phone,
|
||||
list_type=list_type,
|
||||
)
|
||||
return {"text": f"Set {target_phone} as {list_type}.", "metadata": {}}
|
||||
|
||||
meta = inbound.metadata if isinstance(inbound.metadata, dict) else {}
|
||||
quote = extract_quote_context(meta)
|
||||
stanza_id = str(quote.get("stanza_id") or "").strip()
|
||||
quoted_text = str(quote.get("quoted_text") or "").strip()
|
||||
if not stanza_id and not quoted_text:
|
||||
return None
|
||||
|
||||
admin_chat_id = str(getattr(inbound, "external_chat_id", None) or external_user_id or "").strip()
|
||||
pending_id = ""
|
||||
if stanza_id:
|
||||
found = store.find_whatsapp_pending_by_notify_stanza(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
admin_chat_id=admin_chat_id,
|
||||
notify_stanza_id=stanza_id,
|
||||
)
|
||||
if found:
|
||||
pending_id = str(found)
|
||||
|
||||
if not pending_id and quoted_text:
|
||||
fallback = parse_pending_id_from_notify_text(quoted_text)
|
||||
if fallback:
|
||||
pending_id = fallback
|
||||
|
||||
if not pending_id:
|
||||
return None
|
||||
|
||||
item = store.get_whatsapp_access_pending_by_id(pending_id=pending_id)
|
||||
if not item or str(item.get("status") or "").strip().lower() != "pending":
|
||||
return None
|
||||
|
||||
intent = parse_admin_approval_intent(text)
|
||||
if not intent:
|
||||
return None
|
||||
|
||||
target_jid = str(item.get("external_user_id") or "").strip()
|
||||
target_name = str(item.get("push_name") or "").strip()
|
||||
target_phone = str(item.get("phone") or "").strip() or resolve_sender_phone(target_jid)
|
||||
approved = intent == "approve"
|
||||
if approved:
|
||||
store.apply_whatsapp_contact_access(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
external_user_id=target_phone,
|
||||
push_name=target_name,
|
||||
phone=target_phone,
|
||||
list_type="whitelist",
|
||||
)
|
||||
store.resolve_whatsapp_access_pending(
|
||||
pending_id=pending_id,
|
||||
status="approved",
|
||||
resolved_by=external_user_id,
|
||||
)
|
||||
else:
|
||||
if target_phone:
|
||||
store.apply_whatsapp_contact_access(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
external_user_id=target_phone,
|
||||
push_name=target_name,
|
||||
phone=target_phone,
|
||||
list_type="blacklist",
|
||||
)
|
||||
store.resolve_whatsapp_access_pending(
|
||||
pending_id=pending_id,
|
||||
status="denied",
|
||||
resolved_by=external_user_id,
|
||||
)
|
||||
deleter = getattr(store, "delete_whatsapp_pending_notify_for_pending", None)
|
||||
if callable(deleter):
|
||||
deleter(pending_id=pending_id)
|
||||
|
||||
raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
|
||||
reply_meta: dict[str, Any] = {
|
||||
"quote_remote_jid": admin_chat_id,
|
||||
"quote_stanza_id": str(raw.get("id") or "").strip(),
|
||||
"quote_participant": str(raw.get("participant") or external_user_id or "").strip(),
|
||||
"quote_text": str(text or "").strip(),
|
||||
}
|
||||
return {
|
||||
"text": admin_approval_result_text(
|
||||
lang=lang,
|
||||
approved=approved,
|
||||
push_name=target_name,
|
||||
external_user_id=target_jid,
|
||||
),
|
||||
"metadata": reply_meta,
|
||||
}
|
||||
|
||||
|
||||
def handle_whatsapp_access(
|
||||
store: Any,
|
||||
*,
|
||||
inbound: Any,
|
||||
account_id: str,
|
||||
text: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return an inbound response dict when access gate short-circuits normal processing."""
|
||||
tenant_id = resolve_whatsapp_tenant_id(store, account_id=account_id)
|
||||
meta = inbound.metadata if isinstance(inbound.metadata, dict) else {}
|
||||
participant_alt = extract_participant_alt(meta)
|
||||
remote_jid_alt = extract_remote_jid_alt(meta)
|
||||
push_name, phone, raw_jid, canonical_jid = _extract_contact_fields(inbound)
|
||||
if not raw_jid:
|
||||
return None
|
||||
|
||||
cfg = store.get_whatsapp_access_config(tenant_id=tenant_id, account_id=account_id)
|
||||
access_mode = str((cfg or {}).get("access_mode") or default_access_mode())
|
||||
lang = str((cfg or {}).get("lang") or default_access_lang())
|
||||
|
||||
matched = store.find_whatsapp_contact_for_sender(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
external_user_id=raw_jid,
|
||||
participant_alt=participant_alt,
|
||||
remote_jid_alt=remote_jid_alt,
|
||||
)
|
||||
list_type = str((matched or {}).get("list_type") or "").strip().lower() or None
|
||||
|
||||
if list_type == "admin":
|
||||
admin_reply = _handle_admin_message(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
external_user_id=raw_jid,
|
||||
text=text,
|
||||
lang=lang,
|
||||
inbound=inbound,
|
||||
)
|
||||
if admin_reply:
|
||||
return {
|
||||
"ok": True,
|
||||
"replies": [
|
||||
{
|
||||
"channel": "whatsapp",
|
||||
"chat_id": inbound.external_chat_id,
|
||||
"text": str(admin_reply.get("text") or ""),
|
||||
"attachments": [],
|
||||
"metadata": admin_reply.get("metadata")
|
||||
if isinstance(admin_reply.get("metadata"), dict)
|
||||
else {},
|
||||
}
|
||||
],
|
||||
"whatsapp_access": "admin_command",
|
||||
}
|
||||
_upsert_whatsapp_contact_profile(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
raw_jid=raw_jid,
|
||||
canonical_jid=canonical_jid,
|
||||
push_name=push_name,
|
||||
phone=phone,
|
||||
)
|
||||
_ensure_admin_identity(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
external_user_id=raw_jid,
|
||||
participant_alt=participant_alt,
|
||||
push_name=push_name,
|
||||
phone=phone,
|
||||
)
|
||||
return None
|
||||
|
||||
if is_access_allowed(access_mode=access_mode, list_type=list_type):
|
||||
_upsert_whatsapp_contact_profile(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
raw_jid=raw_jid,
|
||||
canonical_jid=canonical_jid,
|
||||
push_name=push_name,
|
||||
phone=phone,
|
||||
)
|
||||
_ensure_guest_identity(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
external_user_id=raw_jid,
|
||||
participant_alt=participant_alt,
|
||||
push_name=push_name,
|
||||
phone=phone,
|
||||
)
|
||||
return None
|
||||
|
||||
pending_id = store.create_whatsapp_access_pending(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
external_user_id=raw_jid,
|
||||
push_name=push_name,
|
||||
phone=phone,
|
||||
request_text=text,
|
||||
)
|
||||
if pending_id:
|
||||
_notify_admins(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
lang=lang,
|
||||
push_name=push_name,
|
||||
external_user_id=raw_jid,
|
||||
request_text=text,
|
||||
pending_id=pending_id,
|
||||
)
|
||||
|
||||
reply_meta: dict[str, Any] = {}
|
||||
if bool(getattr(inbound, "is_group", False)):
|
||||
raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
|
||||
stanza_id = str(raw.get("id") or "").strip()
|
||||
quote_participant = str(raw.get("participant") or raw_jid or "").strip()
|
||||
reply_meta = {
|
||||
"mention_jids": [raw_jid],
|
||||
"quote_remote_jid": str(inbound.external_chat_id or "").strip(),
|
||||
"quote_stanza_id": stanza_id,
|
||||
"quote_participant": quote_participant,
|
||||
"quote_text": str(text or "").strip(),
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"replies": [
|
||||
{
|
||||
"channel": "whatsapp",
|
||||
"chat_id": inbound.external_chat_id,
|
||||
"text": denied_reply_text(lang=lang),
|
||||
"attachments": [],
|
||||
"metadata": reply_meta,
|
||||
}
|
||||
],
|
||||
"whatsapp_access": "denied",
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["handle_whatsapp_access"]
|
||||
392
runtime/extensions/whatsapp/access_control.py
Normal file
392
runtime/extensions/whatsapp/access_control.py
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from runtime.extensions.whatsapp.api import normalize_whatsapp_target
|
||||
|
||||
AccessMode = Literal["blacklist", "whitelist"]
|
||||
ListType = Literal["admin", "whitelist", "blacklist"]
|
||||
ApprovalIntent = Literal["approve", "deny"]
|
||||
|
||||
_AFFIRMATIVE = frozenset(
|
||||
{
|
||||
"yes",
|
||||
"y",
|
||||
"approve",
|
||||
"allow",
|
||||
"add",
|
||||
"ok",
|
||||
"grant",
|
||||
"whitelist",
|
||||
"同意",
|
||||
"可以",
|
||||
"是",
|
||||
"添加",
|
||||
"通过",
|
||||
}
|
||||
)
|
||||
_NEGATIVE = frozenset(
|
||||
{
|
||||
"no",
|
||||
"n",
|
||||
"deny",
|
||||
"reject",
|
||||
"ignore",
|
||||
"拒绝",
|
||||
"不",
|
||||
"否",
|
||||
"忽略",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def default_access_mode() -> AccessMode:
|
||||
return "blacklist"
|
||||
|
||||
|
||||
def default_access_lang() -> str:
|
||||
return "en"
|
||||
|
||||
|
||||
def is_access_allowed(*, access_mode: str, list_type: str | None) -> bool:
|
||||
lt = str(list_type or "").strip().lower()
|
||||
if lt == "admin":
|
||||
return True
|
||||
mode = str(access_mode or default_access_mode()).strip().lower()
|
||||
if mode == "whitelist":
|
||||
return lt != "blacklist"
|
||||
return lt == "whitelist"
|
||||
|
||||
|
||||
def extract_push_name(metadata: dict[str, Any] | None) -> str:
|
||||
if not isinstance(metadata, dict):
|
||||
return ""
|
||||
for key in ("push_name", "display_name", "pushName"):
|
||||
val = metadata.get(key)
|
||||
if val is not None and str(val).strip():
|
||||
return str(val).strip()
|
||||
raw = metadata.get("raw")
|
||||
if isinstance(raw, dict):
|
||||
for key in ("pushName", "push_name", "display_name"):
|
||||
val = raw.get(key)
|
||||
if val is not None and str(val).strip():
|
||||
return str(val).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def is_whatsapp_lid_jid(jid: str) -> bool:
|
||||
return str(jid or "").strip().lower().endswith("@lid")
|
||||
|
||||
|
||||
def phone_from_jid(jid: str) -> str:
|
||||
if is_whatsapp_lid_jid(jid):
|
||||
return ""
|
||||
base = str(jid or "").split("@", 1)[0].strip()
|
||||
digits = re.sub(r"\D", "", base)
|
||||
return digits or base
|
||||
|
||||
|
||||
def normalize_whatsapp_phone(value: str) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
raise ValueError("phone is required")
|
||||
digits = phone_from_jid(raw) if "@" in raw else re.sub(r"\D", "", raw.lstrip("+"))
|
||||
if len(digits) < 6:
|
||||
raise ValueError(f"invalid phone: {value}")
|
||||
return digits
|
||||
|
||||
|
||||
def resolve_sender_phone(
|
||||
external_user_id: str,
|
||||
participant_alt: str = "",
|
||||
remote_jid_alt: str = "",
|
||||
) -> str:
|
||||
for alt in (str(participant_alt or "").strip(), str(remote_jid_alt or "").strip()):
|
||||
if alt.lower().endswith("@s.whatsapp.net"):
|
||||
alt_phone = phone_from_jid(alt)
|
||||
if len(alt_phone) >= 6:
|
||||
return alt_phone
|
||||
canonical = resolve_whatsapp_sender_jid(
|
||||
external_user_id,
|
||||
{
|
||||
"raw": {
|
||||
"participantAlt": str(participant_alt or "").strip() or None,
|
||||
"remoteJidAlt": str(remote_jid_alt or "").strip() or None,
|
||||
}
|
||||
},
|
||||
)
|
||||
if str(canonical or "").lower().endswith("@s.whatsapp.net"):
|
||||
canonical_phone = phone_from_jid(canonical)
|
||||
if len(canonical_phone) >= 6:
|
||||
return canonical_phone
|
||||
if not is_whatsapp_lid_jid(external_user_id):
|
||||
raw_phone = phone_from_jid(external_user_id)
|
||||
if len(raw_phone) >= 6:
|
||||
return raw_phone
|
||||
return ""
|
||||
|
||||
|
||||
def contact_phone_key(row: dict[str, Any] | None) -> str:
|
||||
if not isinstance(row, dict):
|
||||
return ""
|
||||
phone = str(row.get("phone") or "").strip()
|
||||
if phone:
|
||||
return phone
|
||||
return phone_from_jid(str(row.get("external_user_id") or ""))
|
||||
|
||||
|
||||
def whatsapp_phones_match(a: str, b: str) -> bool:
|
||||
left = str(a or "").strip()
|
||||
right = str(b or "").strip()
|
||||
if not left or not right:
|
||||
return False
|
||||
try:
|
||||
return normalize_whatsapp_phone(left) == normalize_whatsapp_phone(right)
|
||||
except Exception:
|
||||
left_phone = phone_from_jid(left)
|
||||
right_phone = phone_from_jid(right)
|
||||
return bool(len(left_phone) >= 6 and left_phone == right_phone)
|
||||
|
||||
|
||||
def jid_local_part(jid: str) -> str:
|
||||
return str(jid or "").split("@", 1)[0].split(":", 1)[0].strip().lower()
|
||||
|
||||
|
||||
def extract_remote_jid_alt(metadata: dict[str, Any] | None) -> str:
|
||||
if not isinstance(metadata, dict):
|
||||
return ""
|
||||
raw = metadata.get("raw")
|
||||
if isinstance(raw, dict):
|
||||
for key in ("remoteJidAlt", "remote_jid_alt"):
|
||||
val = raw.get(key)
|
||||
if val is not None and str(val).strip():
|
||||
return str(val).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def extract_participant_alt(metadata: dict[str, Any] | None) -> str:
|
||||
if not isinstance(metadata, dict):
|
||||
return ""
|
||||
raw = metadata.get("raw")
|
||||
if isinstance(raw, dict):
|
||||
for key in ("participantAlt", "participant_alt"):
|
||||
val = raw.get(key)
|
||||
if val is not None and str(val).strip():
|
||||
return str(val).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_whatsapp_sender_jid(external_user_id: str, metadata: dict[str, Any] | None = None) -> str:
|
||||
jid = str(external_user_id or "").strip()
|
||||
participant_alt = extract_participant_alt(metadata)
|
||||
remote_jid_alt = extract_remote_jid_alt(metadata)
|
||||
low = jid.lower()
|
||||
for alt in (participant_alt, remote_jid_alt):
|
||||
if alt and low.endswith("@lid"):
|
||||
try:
|
||||
return normalize_whatsapp_target(alt)
|
||||
except Exception:
|
||||
pass
|
||||
if low.endswith("@s.whatsapp.net"):
|
||||
return low
|
||||
if low.endswith("@lid"):
|
||||
for alt in (participant_alt, remote_jid_alt):
|
||||
if alt:
|
||||
try:
|
||||
return normalize_whatsapp_target(alt)
|
||||
except Exception:
|
||||
pass
|
||||
return jid
|
||||
try:
|
||||
return normalize_whatsapp_target(jid)
|
||||
except Exception:
|
||||
return jid
|
||||
|
||||
|
||||
def whatsapp_sender_lookup_jids(external_user_id: str, participant_alt: str = "") -> tuple[str, ...]:
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
canonical = resolve_whatsapp_sender_jid(
|
||||
external_user_id,
|
||||
{"raw": {"participantAlt": participant_alt}} if participant_alt else None,
|
||||
)
|
||||
for item in (external_user_id, participant_alt, canonical):
|
||||
val = str(item or "").strip()
|
||||
if val and val not in seen:
|
||||
seen.add(val)
|
||||
out.append(val)
|
||||
low = str(external_user_id or "").lower()
|
||||
if low.endswith("@lid"):
|
||||
alias = f"{jid_local_part(external_user_id)}@s.whatsapp.net"
|
||||
if alias not in seen:
|
||||
seen.add(alias)
|
||||
out.append(alias)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def whatsapp_users_match(a: str, b: str) -> bool:
|
||||
left = str(a or "").strip()
|
||||
right = str(b or "").strip()
|
||||
if not left or not right:
|
||||
return False
|
||||
if whatsapp_phones_match(left, right):
|
||||
return True
|
||||
if left.lower() == right.lower():
|
||||
return True
|
||||
if jid_local_part(left) == jid_local_part(right):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_quote_context(metadata: dict[str, Any] | None) -> dict[str, str]:
|
||||
out = {
|
||||
"stanza_id": "",
|
||||
"quoted_text": "",
|
||||
"remote_jid": "",
|
||||
"participant": "",
|
||||
}
|
||||
if not isinstance(metadata, dict):
|
||||
return out
|
||||
raw = metadata.get("raw")
|
||||
if not isinstance(raw, dict):
|
||||
return out
|
||||
out["stanza_id"] = str(
|
||||
raw.get("quotedStanzaId") or raw.get("quoted_stanza_id") or ""
|
||||
).strip()
|
||||
out["quoted_text"] = str(
|
||||
raw.get("quotedText") or raw.get("quoted_text") or ""
|
||||
).strip()
|
||||
out["participant"] = str(
|
||||
raw.get("quotedParticipant")
|
||||
or raw.get("quoted_participant")
|
||||
or raw.get("participant")
|
||||
or ""
|
||||
).strip()
|
||||
for key in ("quotedRemoteJid", "quoted_remote_jid", "remoteJid"):
|
||||
val = raw.get(key)
|
||||
if val is not None and str(val).strip():
|
||||
out["remote_jid"] = str(val).strip()
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
_PENDING_ID_IN_NOTIFY_RE = re.compile(
|
||||
r"(?:请求编号|Request)\s*[::]\s*([0-9a-f]{16,64})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def parse_pending_id_from_notify_text(text: str) -> str | None:
|
||||
m = _PENDING_ID_IN_NOTIFY_RE.search(str(text or ""))
|
||||
if not m:
|
||||
return None
|
||||
return str(m.group(1) or "").strip() or None
|
||||
|
||||
|
||||
def admin_approval_quote_required_text(*, lang: str) -> str:
|
||||
if str(lang or "").strip().lower().startswith("zh"):
|
||||
return "请引用待审批通知消息后回复「同意」或「拒绝」。"
|
||||
return "Reply YES or NO by quoting the pending access notification."
|
||||
|
||||
|
||||
def admin_approval_unknown_notify_text(*, lang: str) -> str:
|
||||
if str(lang or "").strip().lower().startswith("zh"):
|
||||
return "无法识别该审批请求,请引用正确的通知消息。"
|
||||
return "Cannot identify this approval request. Please quote the correct notification message."
|
||||
|
||||
|
||||
def parse_admin_approval_intent(text: str) -> ApprovalIntent | None:
|
||||
blob = str(text or "").strip().lower()
|
||||
if not blob:
|
||||
return None
|
||||
first = re.split(r"[\s,,。.!!??]+", blob, maxsplit=1)[0].strip()
|
||||
if first in _AFFIRMATIVE or any(tok in blob for tok in ("add to whitelist", "grant access", "添加白名单", "加入白名单")):
|
||||
return "approve"
|
||||
if first in _NEGATIVE or any(tok in blob for tok in ("do not add", "don't add", "不要添加", "不加")):
|
||||
return "deny"
|
||||
return None
|
||||
|
||||
|
||||
def parse_admin_access_command(text: str) -> dict[str, Any] | None:
|
||||
raw = str(text or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
low = raw.lower()
|
||||
|
||||
mode_match = re.match(r"^(?:access\s+mode|模式)\s+(blacklist|whitelist|黑名单|白名单)\b", low)
|
||||
if mode_match:
|
||||
token = mode_match.group(1)
|
||||
if token in {"黑名单", "blacklist"}:
|
||||
return {"action": "set_mode", "access_mode": "blacklist"}
|
||||
return {"action": "set_mode", "access_mode": "whitelist"}
|
||||
|
||||
for verb, list_type in (
|
||||
(r"^(?:whitelist|白名单)\s+(?:add\s+)?(.+)$", "whitelist"),
|
||||
(r"^(?:blacklist|黑名单)\s+(?:add\s+)?(.+)$", "blacklist"),
|
||||
(r"^(?:admin|管理员)\s+(?:add\s+)?(.+)$", "admin"),
|
||||
):
|
||||
m = re.match(verb, raw, flags=re.IGNORECASE)
|
||||
if m:
|
||||
target = str(m.group(1) or "").strip()
|
||||
if target:
|
||||
return {"action": "set_list", "list_type": list_type, "target": target}
|
||||
return None
|
||||
|
||||
|
||||
def normalize_contact_jid(value: str) -> str:
|
||||
return normalize_whatsapp_target(str(value or "").strip())
|
||||
|
||||
|
||||
def coerce_whatsapp_access_target(value: str) -> str:
|
||||
"""Normalize manual contact input to canonical WhatsApp user JID from phone."""
|
||||
return normalize_whatsapp_target(normalize_whatsapp_phone(value))
|
||||
|
||||
|
||||
def denied_reply_text(*, lang: str) -> str:
|
||||
if str(lang or "").strip().lower().startswith("zh"):
|
||||
return "无权限:您尚未获得使用此助手的授权。请联系管理员。"
|
||||
return "Access denied: you are not authorized to use this assistant. Please contact an administrator."
|
||||
|
||||
|
||||
def admin_notify_text(
|
||||
*,
|
||||
lang: str,
|
||||
push_name: str,
|
||||
external_user_id: str,
|
||||
request_text: str,
|
||||
pending_id: str,
|
||||
) -> str:
|
||||
phone = phone_from_jid(external_user_id)
|
||||
name = str(push_name or "").strip() or phone or external_user_id
|
||||
preview = str(request_text or "").strip()
|
||||
if len(preview) > 160:
|
||||
preview = preview[:157] + "..."
|
||||
if str(lang or "").strip().lower().startswith("zh"):
|
||||
return (
|
||||
f"[oclaw] 未授权用户请求访问\n"
|
||||
f"用户: {name}\n"
|
||||
f"ID: {external_user_id}\n"
|
||||
f"消息: {preview or '(empty)'}\n"
|
||||
f"请求编号: {pending_id}\n"
|
||||
f"请引用本条消息回复「同意」或「拒绝」。"
|
||||
)
|
||||
return (
|
||||
f"[oclaw] Unauthorized access request\n"
|
||||
f"User: {name}\n"
|
||||
f"ID: {external_user_id}\n"
|
||||
f"Message: {preview or '(empty)'}\n"
|
||||
f"Request: {pending_id}\n"
|
||||
f"Reply YES or NO by quoting this message."
|
||||
)
|
||||
|
||||
|
||||
def admin_approval_result_text(*, lang: str, approved: bool, push_name: str, external_user_id: str) -> str:
|
||||
name = str(push_name or "").strip() or phone_from_jid(external_user_id) or external_user_id
|
||||
if str(lang or "").strip().lower().startswith("zh"):
|
||||
if approved:
|
||||
return f"已将 {name} ({external_user_id}) 加入白名单。"
|
||||
return f"已忽略 {name} ({external_user_id}) 的访问请求。"
|
||||
if approved:
|
||||
return f"Whitelisted {name} ({external_user_id})."
|
||||
return f"Ignored access request from {name} ({external_user_id})."
|
||||
14
runtime/extensions/whatsapp/tenant.py
Normal file
14
runtime/extensions/whatsapp/tenant.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
||||
def resolve_whatsapp_tenant_id(store: Any, *, account_id: str) -> str:
|
||||
account = store.find_user_by_channel_account(channel="whatsapp", account_id=str(account_id or "").strip())
|
||||
if account and str(account.get("tenant_id") or "").strip():
|
||||
return str(account["tenant_id"])
|
||||
return str(os.getenv("OCLAW_DEFAULT_TENANT_ID") or "default").strip() or "default"
|
||||
|
||||
|
||||
__all__ = ["resolve_whatsapp_tenant_id"]
|
||||
|
|
@ -162,6 +162,36 @@ function resolveSenderJid(key: proto.IMessageKey): string {
|
|||
return "";
|
||||
}
|
||||
|
||||
function resolvePnFromLid(sock: ReturnType<typeof makeWASocket> | null, lidJid: string): string {
|
||||
const lid = String(lidJid || "").trim();
|
||||
if (!lid || !sock) return "";
|
||||
const lidMapping = (sock as any)?.signalRepository?.lidMapping;
|
||||
if (!lidMapping || typeof lidMapping.getPNForLID !== "function") return "";
|
||||
try {
|
||||
const pn = lidMapping.getPNForLID(lid);
|
||||
return pn ? jidNormalizedUser(String(pn)) : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDmUserJid(key: proto.IMessageKey, sock: ReturnType<typeof makeWASocket> | null): string {
|
||||
const remoteJid = jidNormalizedUser(String(key.remoteJid || ""));
|
||||
const remoteJidAlt = String((key as any).remoteJidAlt || "").trim();
|
||||
if (remoteJidAlt) {
|
||||
const alt = jidNormalizedUser(remoteJidAlt);
|
||||
if (remoteJid.toLowerCase().endsWith("@lid") && alt.toLowerCase().includes("@s.whatsapp")) {
|
||||
return alt;
|
||||
}
|
||||
if (alt.toLowerCase().includes("@s.whatsapp")) return alt;
|
||||
}
|
||||
if (remoteJid.toLowerCase().endsWith("@lid")) {
|
||||
const pn = resolvePnFromLid(sock, remoteJid);
|
||||
if (pn) return pn;
|
||||
}
|
||||
return remoteJid;
|
||||
}
|
||||
|
||||
function jidBaseLocal(jid: string): string {
|
||||
const s = String(jid || "").trim().toLowerCase();
|
||||
if (!s) return "";
|
||||
|
|
@ -727,21 +757,31 @@ async function pollOutboundQueue(sock: ReturnType<typeof makeWASocket>): Promise
|
|||
let ok = true;
|
||||
let err = "";
|
||||
try {
|
||||
await sock.sendMessage(chatId, { text });
|
||||
log(`outbound sent id=${id} chat=${chatId}`);
|
||||
const sent = await sock.sendMessage(chatId, { text });
|
||||
const stanzaId = String((sent as any)?.key?.id || "").trim();
|
||||
log(`outbound sent id=${id} chat=${chatId} stanza=${stanzaId || "?"}`);
|
||||
try {
|
||||
await fetch(`${LOCAL_BASE_URL.replace(/\/+$/, "")}/whatsapp/outbound/ack`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ok, error: err, stanza_id: stanzaId }),
|
||||
});
|
||||
} catch {
|
||||
// best-effort ack
|
||||
}
|
||||
} catch (sendErr) {
|
||||
ok = false;
|
||||
err = String(sendErr);
|
||||
log(`outbound send failed id=${id} chat=${chatId} err=${err.slice(0, 160)}`);
|
||||
}
|
||||
try {
|
||||
await fetch(`${LOCAL_BASE_URL.replace(/\/+$/, "")}/whatsapp/outbound/ack`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ok, error: err }),
|
||||
});
|
||||
} catch {
|
||||
// best-effort ack
|
||||
try {
|
||||
await fetch(`${LOCAL_BASE_URL.replace(/\/+$/, "")}/whatsapp/outbound/ack`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ok, error: err }),
|
||||
});
|
||||
} catch {
|
||||
// best-effort ack
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -933,10 +973,12 @@ async function main(): Promise<void> {
|
|||
const isGroup = remoteJid.endsWith("@g.us");
|
||||
const participantRaw = String(key.participant || "").trim();
|
||||
const participantAlt = String((key as any).participantAlt || "").trim();
|
||||
const remoteJidAlt = String((key as any).remoteJidAlt || "").trim();
|
||||
const remoteJidNorm = jidNormalizedUser(remoteJid);
|
||||
const userId = isGroup
|
||||
? resolveSenderJid(key) || jidNormalizedUser(remoteJid)
|
||||
: jidNormalizedUser(remoteJid);
|
||||
const chatId = jidNormalizedUser(remoteJid);
|
||||
? resolveSenderJid(key) || remoteJidNorm
|
||||
: resolveDmUserJid(key, sock);
|
||||
const chatId = remoteJidNorm;
|
||||
const mentions = extractMentionsFromUpsert(msg);
|
||||
const quote = extractQuoteContext(msg.message);
|
||||
const botJidRaw = sock?.user?.id ? String(sock.user.id).trim() : "";
|
||||
|
|
@ -960,6 +1002,7 @@ async function main(): Promise<void> {
|
|||
const raw = {
|
||||
id,
|
||||
remoteJid,
|
||||
remoteJidAlt: remoteJidAlt || null,
|
||||
participant: participantRaw || null,
|
||||
participantAlt: participantAlt || null,
|
||||
pushName: (msg as any).pushName || null,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue