oclaw/tests/test_group_ingest.py
oliver 900e3d0d3e feat(whatsapp): gate group replies on mention and share group sessions
Stop replying to every group message by filtering on @bot/triggers in the sidecar and gateway, and map group chats to one shared session while preserving per-sender identity.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 18:49:59 +08:00

287 lines
9.1 KiB
Python

from __future__ import annotations
import pytest
from runtime.orchestration.group_ingest import (
GROUP_SESSION_USER_SENTINEL,
build_group_sender_context,
normalize_jid,
resolve_group_policy,
session_user_key,
should_process_group_inbound,
)
from runtime.application.gateway.inbound_service import process_inbound_payload
from svc.persistence.db.engine import clear_assistant_engine_cache
from svc.persistence.sqlite_store import SqliteStore
@pytest.fixture
def fresh_sqlite_store(monkeypatch: pytest.MonkeyPatch, tmp_path):
monkeypatch.delenv("AIA_ASSISTANT_DB_BACKEND", raising=False)
monkeypatch.setenv("OPS_WORKSPACE_ROOT", str(tmp_path))
dbfile = tmp_path / "group_ingest.sqlite"
monkeypatch.setenv("AIA_ASSISTANT_DB_PATH", str(dbfile))
clear_assistant_engine_cache()
s = SqliteStore(str(dbfile))
try:
yield s
finally:
clear_assistant_engine_cache()
def test_normalize_jid_strips_device_suffix() -> None:
assert normalize_jid("123456:12@s.whatsapp.net") == "123456@s.whatsapp.net"
def test_should_process_direct_messages_always() -> None:
assert should_process_group_inbound(is_group=False, text="hi", mentions=[], bot_jid="bot@s.whatsapp.net") is True
def test_should_drop_group_without_mention_or_trigger() -> None:
assert (
should_process_group_inbound(
is_group=True,
text="大家好",
mentions=[],
bot_jid="999@s.whatsapp.net",
require_mention=True,
triggers=["/oclaw"],
)
is False
)
def test_should_accept_group_when_bot_mentioned() -> None:
assert (
should_process_group_inbound(
is_group=True,
text="@bot hello",
mentions=["999:0@s.whatsapp.net"],
bot_jid="999@s.whatsapp.net",
require_mention=True,
)
is True
)
def test_should_accept_group_trigger_without_mention() -> None:
assert (
should_process_group_inbound(
is_group=True,
text="/oclaw 查天气",
mentions=[],
bot_jid="999@s.whatsapp.net",
require_mention=True,
triggers=["/oclaw"],
)
is True
)
def test_session_user_key_group_sentinel() -> None:
assert session_user_key(is_group=True, external_user_id="111@s.whatsapp.net") == GROUP_SESSION_USER_SENTINEL
assert session_user_key(is_group=False, external_user_id="111@s.whatsapp.net") == "111@s.whatsapp.net"
def test_resolve_group_policy_from_account_config() -> None:
policy = resolve_group_policy(
account={
"config": {
"group_policy": {
"require_mention": False,
"triggers": ["!ask"],
}
}
}
)
assert policy.require_mention is False
assert policy.triggers == ("!ask",)
def test_build_group_sender_context() -> None:
ctx = build_group_sender_context(
metadata={"raw": {"pushName": "Alice"}},
external_user_id="111@s.whatsapp.net",
)
assert "Alice" in ctx
assert "111@s.whatsapp.net" in ctx
def test_shared_group_session_for_multiple_senders(fresh_sqlite_store: SqliteStore) -> None:
store = fresh_sqlite_store
tenant = store.create_tenant("WA")
chat_id = "120363012345678@g.us"
sid_a = store.get_or_create_channel_session_v2(
tenant_id=str(tenant["id"]),
channel="whatsapp",
account_id="wa-default",
external_chat_id=chat_id,
external_user_id=session_user_key(is_group=True, external_user_id="111@s.whatsapp.net"),
session_title="whatsapp|test+Family",
)
sid_b = store.get_or_create_channel_session_v2(
tenant_id=str(tenant["id"]),
channel="whatsapp",
account_id="wa-default",
external_chat_id=chat_id,
external_user_id=session_user_key(is_group=True, external_user_id="222@s.whatsapp.net"),
session_title="whatsapp|test+Family",
)
assert sid_a == sid_b
def _setup_whatsapp_identity(store: SqliteStore, *, extra_user_ids: list[str] | None = None) -> tuple[str, str]:
tenant = store.create_tenant("WA")
tenant_id = str(tenant["id"])
user = store.create_user(tenant_id=tenant_id, display_name="Admin", role="owner")
user_id = str(user["id"])
store.upsert_user_channel_account(
tenant_id=tenant_id,
user_id=user_id,
channel="whatsapp",
account_id="wa-default",
name="wa-default",
config={"group_policy": {"require_mention": True, "triggers": ["/oclaw"]}},
is_active=True,
)
for ext_uid in ["111@s.whatsapp.net", *(extra_user_ids or [])]:
store.upsert_channel_identity_v2(
tenant_id=tenant_id,
channel="whatsapp",
account_id="wa-default",
external_user_id=ext_uid,
user_id=user_id,
)
return tenant_id, user_id
def test_inbound_group_without_mention_is_silent(monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore) -> None:
store = fresh_sqlite_store
_setup_whatsapp_identity(store)
monkeypatch.setattr("svc.persistence.assistant_store.get_assistant_store", lambda: store)
out = process_inbound_payload(
{
"channel": "whatsapp",
"account_id": "wa-default",
"user_id": "111@s.whatsapp.net",
"chat_id": "120363012345678@g.us",
"text": "大家晚上好",
"is_group": True,
"mentions": [],
"metadata": {"bot_jid": "999@s.whatsapp.net", "source": "test"},
}
)
assert out.get("ok") is True
assert out.get("replies") == []
def test_inbound_dm_still_processes_without_mention(monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore) -> None:
store = fresh_sqlite_store
_setup_whatsapp_identity(store)
monkeypatch.setattr("svc.persistence.assistant_store.get_assistant_store", lambda: store)
captured: dict[str, str] = {}
class _Turn:
turn_uuid = "turn-1"
reply_text = "pong"
class _Gw:
def __init__(self, *, store: object) -> None:
_ = store
def handle_turn(self, **kwargs: object) -> _Turn:
msg = kwargs.get("msg")
captured["text"] = str(getattr(msg, "text", "") or "")
return _Turn()
monkeypatch.setattr("runtime.gateway.OclawGateway", _Gw)
out = process_inbound_payload(
{
"channel": "whatsapp",
"account_id": "wa-default",
"user_id": "111@s.whatsapp.net",
"chat_id": "111@s.whatsapp.net",
"text": "ping",
"is_group": False,
"mentions": [],
"metadata": {"bot_jid": "999@s.whatsapp.net"},
}
)
replies = out.get("replies") if isinstance(out.get("replies"), list) else []
assert replies and str(replies[0].get("text") or "") == "pong"
assert "[群成员:" not in captured.get("text", "")
def test_inbound_group_mention_uses_shared_session_and_sender_prefix(
monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore
) -> None:
store = fresh_sqlite_store
tenant_id, user_id = _setup_whatsapp_identity(store, extra_user_ids=["222@s.whatsapp.net"])
monkeypatch.setattr("svc.persistence.assistant_store.get_assistant_store", lambda: store)
captured: dict[str, str] = {}
session_ids: list[str] = []
class _Turn:
turn_uuid = "turn-g"
reply_text = "group-ok"
class _Gw:
def __init__(self, *, store: object) -> None:
_ = store
def handle_turn(self, **kwargs: object) -> _Turn:
msg = kwargs.get("msg")
captured["text"] = str(getattr(msg, "text", "") or "")
session_ids.append(str(getattr(msg, "session_id", "") or ""))
return _Turn()
monkeypatch.setattr("runtime.gateway.OclawGateway", _Gw)
chat_id = "120363012345678@g.us"
base = {
"channel": "whatsapp",
"account_id": "wa-default",
"chat_id": chat_id,
"is_group": True,
"metadata": {"bot_jid": "999@s.whatsapp.net", "source": "test"},
}
process_inbound_payload(
{
**base,
"user_id": "111@s.whatsapp.net",
"text": "@bot hi",
"mentions": ["999@s.whatsapp.net"],
"metadata": {**base["metadata"], "raw": {"pushName": "Alice"}},
}
)
process_inbound_payload(
{
**base,
"user_id": "222@s.whatsapp.net",
"text": "@bot again",
"mentions": ["999@s.whatsapp.net"],
"metadata": {**base["metadata"], "raw": {"pushName": "Bob"}},
}
)
assert len(session_ids) == 2
assert session_ids[0] == session_ids[1]
assert "[群成员:" in captured["text"]
assert "Bob" in captured["text"]
sid = store.get_or_create_channel_session_v2(
tenant_id=tenant_id,
channel="whatsapp",
account_id="wa-default",
external_chat_id=chat_id,
external_user_id=GROUP_SESSION_USER_SENTINEL,
session_title="whatsapp|wa-default+Family",
)
assert sid == session_ids[0]
_ = tenant_id, user_id