oclaw/tests/test_whatsapp_inbound_access.py
oliver 28a5f2586c Dedupe WhatsApp access pending and cap execManagedNe per turn.
Reuse open pending requests without re-notifying admins, clarify already-waiting replies, and block further CLI after 4 calls or 2 failures in the same turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 23:25:09 +08:00

423 lines
17 KiB
Python

from __future__ import annotations
from typing import Any
from runtime.application.gateway.whatsapp_inbound_access import handle_whatsapp_access
class _AccessStore:
def __init__(self) -> None:
self.contacts: dict[str, dict[str, Any]] = {}
self.pending: list[dict[str, Any]] = []
self.outbound: list[dict[str, Any]] = []
self.config: dict[str, Any] = {"access_mode": "blacklist", "lang": "en"}
self.identities: dict[str, dict[str, Any]] = {}
self.users: list[dict[str, Any]] = []
self.notify_map: dict[tuple[str, str], str] = {}
self._pending_seq = 0
def get_whatsapp_access_config(self, *, tenant_id: str, account_id: str) -> dict[str, Any]:
return dict(self.config)
def upsert_whatsapp_access_config(self, **kwargs: Any) -> dict[str, Any]:
self.config.update({k: v for k, v in kwargs.items() if k in {"access_mode", "lang"}})
return dict(self.config)
def apply_whatsapp_contact_access(self, **kwargs: Any) -> dict[str, Any]:
return self.upsert_whatsapp_contact(**kwargs)
def find_whatsapp_contact_for_sender(self, **kwargs: Any) -> dict[str, Any] | None:
from runtime.extensions.whatsapp.access_control import (
contact_phone_key,
resolve_sender_phone,
whatsapp_users_match,
)
jid = str(kwargs.get("external_user_id") or "")
alt = str(kwargs.get("participant_alt") or "")
remote_alt = str(kwargs.get("remote_jid_alt") or "")
sender_phone = resolve_sender_phone(jid, alt, remote_alt)
for row in self.contacts.values():
if not row.get("list_type"):
continue
if sender_phone and contact_phone_key(row) == sender_phone:
return row
cj = str(row.get("external_user_id") or "")
if whatsapp_users_match(cj, jid) or (alt and whatsapp_users_match(cj, alt)):
return row
return None
def resolve_whatsapp_pending_for_sender(self, **kwargs: Any) -> int:
return 0
def upsert_whatsapp_contact(self, **kwargs: Any) -> dict[str, Any]:
jid = str(kwargs.get("external_user_id") or "")
row = dict(self.contacts.get(jid) or {})
row.update({k: v for k, v in kwargs.items() if v is not None})
if kwargs.get("list_type") is None and jid in self.contacts:
row["list_type"] = self.contacts[jid].get("list_type")
self.contacts[jid] = row
return row
def list_whatsapp_contacts(self, *, tenant_id: str, account_id: str, list_type: str | None = None, limit: int = 200) -> list[dict[str, Any]]:
rows = list(self.contacts.values())
if list_type:
rows = [r for r in rows if str(r.get("list_type") or "") == list_type]
return rows
def find_open_whatsapp_access_pending(self, **kwargs: Any) -> dict[str, Any] | None:
jid = str(kwargs.get("external_user_id") or "")
for row in self.pending:
if row.get("status") == "pending" and row.get("external_user_id") == jid:
return dict(row)
return None
def create_whatsapp_access_pending(self, **kwargs: Any) -> str:
existing = self.find_open_whatsapp_access_pending(**kwargs)
if existing:
return str(existing.get("id") or "")
self._pending_seq += 1
pending_id = f"pending{self._pending_seq}"
self.pending.append(
{
"id": pending_id,
"external_user_id": kwargs.get("external_user_id"),
"push_name": kwargs.get("push_name"),
"phone": kwargs.get("phone"),
"request_text": kwargs.get("request_text"),
"status": "pending",
}
)
return pending_id
def list_whatsapp_access_pending(self, **kwargs: Any) -> list[dict[str, Any]]:
return [r for r in self.pending if r.get("status") == kwargs.get("status", "pending")]
def get_whatsapp_access_pending_by_id(self, *, pending_id: str) -> dict[str, Any] | None:
for row in self.pending:
if str(row.get("id") or "") == str(pending_id or ""):
return dict(row)
return None
def find_whatsapp_pending_by_notify_stanza(
self,
*,
tenant_id: str,
account_id: str,
admin_chat_id: str,
notify_stanza_id: str,
) -> str | None:
return self.notify_map.get((str(admin_chat_id), str(notify_stanza_id)))
def delete_whatsapp_pending_notify_for_pending(self, *, pending_id: str) -> int:
removed = 0
for key, pid in list(self.notify_map.items()):
if pid == pending_id:
del self.notify_map[key]
removed += 1
return removed
def resolve_whatsapp_access_pending(self, **kwargs: Any) -> bool:
for row in self.pending:
if row.get("id") == kwargs.get("pending_id"):
row["status"] = kwargs.get("status")
return True
return False
def enqueue_channel_outbound_message(self, **kwargs: Any) -> str:
self.outbound.append(dict(kwargs))
return "out1"
def resolve_user_by_channel_identity_v2(self, **kwargs: Any) -> dict[str, Any] | None:
return self.identities.get(str(kwargs.get("external_user_id") or ""))
def create_user_account(self, **kwargs: Any) -> dict[str, Any]:
user = {"id": "guest1", "role": kwargs.get("role", "guest")}
self.users.append(user)
return user
def upsert_channel_identity_v2(self, **kwargs: Any) -> None:
jid = str(kwargs.get("external_user_id") or "")
self.identities[jid] = {
"tenant_id": kwargs.get("tenant_id"),
"user_id": kwargs.get("user_id"),
"role": "guest",
}
def get_user_by_username(self, **kwargs: Any) -> dict[str, Any] | None:
return {"id": "admin1", "role": "owner"}
class _Inbound:
channel = "whatsapp"
external_user_id = "8615601877957@s.whatsapp.net"
external_chat_id = "8615601877957@s.whatsapp.net"
metadata = {"raw": {"pushName": "Bob"}}
class _InboundGroup:
channel = "whatsapp"
is_group = True
external_user_id = "91010910658657@lid"
external_chat_id = "999@g.us"
metadata = {
"raw": {
"pushName": "oliver",
"id": "stanza_1",
"participant": "91010910658657@lid",
"participantAlt": "8618142387786@s.whatsapp.net",
}
}
class _InboundAdmin:
channel = "whatsapp"
external_user_id = "111@s.whatsapp.net"
external_chat_id = "111@s.whatsapp.net"
metadata: dict[str, Any] = {"raw": {}}
def _setup_admin_store() -> _AccessStore:
store = _AccessStore()
store.contacts["111@s.whatsapp.net"] = {
"external_user_id": "111@s.whatsapp.net",
"phone": "111",
"list_type": "admin",
}
return store
def test_handle_whatsapp_access_admin_yes_without_quote_passes_to_llm(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _setup_admin_store()
store.pending.append(
{
"id": "pending1",
"external_user_id": "8615601877957@s.whatsapp.net",
"push_name": "Bob",
"phone": "8615601877957",
"status": "pending",
}
)
out = handle_whatsapp_access(store, inbound=_InboundAdmin(), account_id="wa-default", text="yes")
assert out is None
assert store.pending[0]["status"] == "pending"
def test_handle_whatsapp_access_admin_quoted_notify_without_intent_passes_to_llm(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _setup_admin_store()
store.pending.append(
{
"id": "pending2",
"external_user_id": "8615601877957@s.whatsapp.net",
"push_name": "Bob",
"phone": "8615601877957",
"status": "pending",
}
)
store.notify_map[("111@s.whatsapp.net", "notify_stanza_2")] = "pending2"
inbound = type("_InboundAdminQuoteHello", (), {
"channel": "whatsapp",
"external_user_id": "111@s.whatsapp.net",
"external_chat_id": "111@s.whatsapp.net",
"metadata": {
"raw": {
"quotedStanzaId": "notify_stanza_2",
"quotedText": "[oclaw] Unauthorized access request",
}
},
})()
out = handle_whatsapp_access(store, inbound=inbound, account_id="wa-default", text="hello")
assert out is None
assert store.pending[0]["status"] == "pending"
def test_handle_whatsapp_access_admin_yes_with_stanza_mapping(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _setup_admin_store()
store.pending.append(
{
"id": "pending2",
"external_user_id": "8615601877957@s.whatsapp.net",
"push_name": "Bob",
"phone": "8615601877957",
"status": "pending",
}
)
store.notify_map[("111@s.whatsapp.net", "notify_stanza_2")] = "pending2"
inbound = type("_InboundAdminQuote", (), {
"channel": "whatsapp",
"external_user_id": "111@s.whatsapp.net",
"external_chat_id": "111@s.whatsapp.net",
"metadata": {
"raw": {
"id": "admin_reply_1",
"participant": "111@s.whatsapp.net",
"quotedStanzaId": "notify_stanza_2",
"quotedText": "[oclaw] Unauthorized access request",
}
},
})()
out = handle_whatsapp_access(store, inbound=inbound, account_id="wa-default", text="yes")
assert out is not None
assert store.pending[0]["status"] == "approved"
assert store.contacts.get("8615601877957", {}).get("list_type") == "whitelist"
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"
def test_handle_whatsapp_access_admin_yes_with_quoted_text_fallback(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _setup_admin_store()
store.pending.append(
{
"id": "abcdef0123456789abcdef0123456789",
"external_user_id": "8615601877957@s.whatsapp.net",
"push_name": "Bob",
"phone": "8615601877957",
"status": "pending",
}
)
inbound = type("_InboundAdminQuoteText", (), {
"channel": "whatsapp",
"external_user_id": "111@s.whatsapp.net",
"external_chat_id": "111@s.whatsapp.net",
"metadata": {
"raw": {
"id": "admin_reply_2",
"participant": "111@s.whatsapp.net",
"quotedStanzaId": "missing_stanza",
"quotedText": "[oclaw] Unauthorized access request\nRequest: abcdef0123456789abcdef0123456789\n",
}
},
})()
out = handle_whatsapp_access(store, inbound=inbound, account_id="wa-default", text="no")
assert out is not None
assert store.pending[0]["status"] == "denied"
assert store.contacts.get("8615601877957", {}).get("list_type") == "blacklist"
def test_handle_whatsapp_access_denied_enqueues_notify_with_pending_source(monkeypatch) -> None:
import json
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _AccessStore()
store.contacts["111@s.whatsapp.net"] = {
"external_user_id": "111@s.whatsapp.net",
"list_type": "admin",
}
out = handle_whatsapp_access(store, inbound=_Inbound(), account_id="wa-default", text="hello")
assert out is not None
assert store.outbound
source = store.outbound[0].get("source")
parsed = json.loads(str(source))
assert parsed.get("kind") == "whatsapp_access_pending"
assert parsed.get("pending_id")
def test_handle_whatsapp_access_allows_admin_via_lid_alias(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _AccessStore()
store.contacts["8618142387786@s.whatsapp.net"] = {
"external_user_id": "8618142387786@s.whatsapp.net",
"phone": "8618142387786",
"list_type": "admin",
}
out = handle_whatsapp_access(store, inbound=_InboundGroup(), account_id="wa-default", text="hello")
assert out is None
def test_handle_whatsapp_access_denied_unknown_user(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _AccessStore()
store.contacts["111@s.whatsapp.net"] = {"external_user_id": "111@s.whatsapp.net", "list_type": "admin"}
out = handle_whatsapp_access(store, inbound=_Inbound(), account_id="wa-default", text="hello")
assert out is not None
assert out.get("whatsapp_access") == "denied"
assert "8615601877957@s.whatsapp.net" not in store.contacts
assert store.pending
assert store.pending[0].get("phone") == "8615601877957"
assert store.outbound
replies = out.get("replies") if isinstance(out.get("replies"), list) else []
text = str((replies[0] or {}).get("text") or "").lower()
assert replies and ("pending" in text or "denied" in text)
def test_handle_whatsapp_access_already_pending_skips_renotify(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _AccessStore()
# blacklist mode: unknown senders are denied and create a pending request
store.config["access_mode"] = "blacklist"
store.contacts["111@s.whatsapp.net"] = {"external_user_id": "111@s.whatsapp.net", "list_type": "admin"}
first = handle_whatsapp_access(store, inbound=_Inbound(), account_id="wa-default", text="hello")
assert first is not None
assert first.get("whatsapp_access") == "denied"
assert first.get("whatsapp_access_already_pending") is not True
assert len(store.outbound) == 1
pending_id = str(store.pending[0].get("id") or "")
second = handle_whatsapp_access(store, inbound=_Inbound(), account_id="wa-default", text="hello again")
assert second is not None
assert second.get("whatsapp_access_already_pending") is True
assert len(store.outbound) == 1 # no second admin notify
assert len(store.pending) == 1
assert store.pending[0].get("id") == pending_id
replies = second.get("replies") if isinstance(second.get("replies"), list) else []
text = str((replies[0] or {}).get("text") or "").lower()
assert "still pending" in text or pending_id.lower() in text
def test_handle_whatsapp_access_allows_whitelisted_user(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _AccessStore()
store.contacts["8615601877957@s.whatsapp.net"] = {
"external_user_id": "8615601877957@s.whatsapp.net",
"phone": "8615601877957",
"list_type": "whitelist",
}
out = handle_whatsapp_access(store, inbound=_Inbound(), account_id="wa-default", text="hello")
assert out is None
assert store.identities.get("8615601877957@s.whatsapp.net")
def test_handle_whatsapp_access_denied_group_includes_mention_and_quote(monkeypatch) -> None:
import runtime.application.gateway.whatsapp_inbound_access as mod
monkeypatch.setattr(mod, "resolve_whatsapp_tenant_id", lambda store, account_id: "tenant1")
store = _AccessStore()
inbound = type("_InboundGroupDenied", (), {
"channel": "whatsapp",
"is_group": True,
"external_user_id": "333@s.whatsapp.net",
"external_chat_id": "999@g.us",
"metadata": {"raw": {"pushName": "Bob", "id": "stanza_1", "participant": "333@s.whatsapp.net"}},
})()
out = handle_whatsapp_access(store, inbound=inbound, account_id="wa-default", text="hello")
assert out is not None
assert out.get("whatsapp_access") == "denied"
replies = out.get("replies") if isinstance(out.get("replies"), list) else []
assert replies and isinstance(replies[0], dict)
md = replies[0].get("metadata") if isinstance(replies[0].get("metadata"), dict) else {}
assert md.get("mention_jids") == ["333@s.whatsapp.net"]
assert md.get("quote_remote_jid") == "999@g.us"
assert md.get("quote_stanza_id") == "stanza_1"
assert md.get("quote_participant") == "333@s.whatsapp.net"