mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 04:40:45 +08:00
Per-user group sessions (user_in_chat) already isolate history; keep Sender tags and focus hints only when session_scope=chat (legacy shared __group__ transcripts). Co-authored-by: Cursor <cursoragent@cursor.com>
1137 lines
37 KiB
Python
1137 lines
37 KiB
Python
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from runtime.orchestration.group_ingest import (
|
||
GROUP_SESSION_USER_SENTINEL,
|
||
build_group_focus_instruction,
|
||
build_group_quoted_context_block,
|
||
build_group_sender_context,
|
||
build_whatsapp_group_reply_metadata,
|
||
extract_group_quoted_message,
|
||
infer_is_group_from_chat_id,
|
||
is_nonsend_channel_reply_text,
|
||
normalize_jid,
|
||
normalize_group_session_scope,
|
||
normalize_mentioned_users_in_text,
|
||
prepare_group_user_text_for_model,
|
||
resolve_group_policy,
|
||
session_user_key,
|
||
should_inject_quoted_context,
|
||
should_process_group_inbound,
|
||
strip_bot_mentions_from_text,
|
||
text_mentions_bot,
|
||
)
|
||
from interfaces.channels.base import InboundMessage
|
||
from runtime.application.gateway.inbound_service import _parse_generic_inbound, 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_mention_with_lid_phone_match() -> None:
|
||
assert (
|
||
should_process_group_inbound(
|
||
is_group=True,
|
||
text="@bot hello",
|
||
mentions=["6281284654304@lid"],
|
||
bot_jid="6281284654304@s.whatsapp.net",
|
||
require_mention=True,
|
||
)
|
||
is True
|
||
)
|
||
|
||
|
||
def test_should_accept_group_mention_with_bot_lid_identity() -> None:
|
||
assert (
|
||
should_process_group_inbound(
|
||
is_group=True,
|
||
text="@bot hello",
|
||
mentions=["176944565977182@lid"],
|
||
bot_jid="6281284654304@s.whatsapp.net",
|
||
require_mention=True,
|
||
metadata={"bot_lid": "176944565977182:2@lid"},
|
||
)
|
||
is True
|
||
)
|
||
|
||
|
||
def test_jids_same_user_lid_device_suffix() -> None:
|
||
from runtime.orchestration.group_ingest import jids_same_user
|
||
|
||
assert jids_same_user("176944565977182@lid", "176944565977182:2@lid") is True
|
||
|
||
|
||
def test_should_accept_group_when_sidecar_reports_mentions_bot_without_jids() -> None:
|
||
assert (
|
||
should_process_group_inbound(
|
||
is_group=True,
|
||
text="@bot hi",
|
||
mentions=[],
|
||
bot_jid="999@s.whatsapp.net",
|
||
require_mention=True,
|
||
metadata={"mentions_bot": True, "raw": {"mentionsBot": True}},
|
||
)
|
||
is True
|
||
)
|
||
|
||
|
||
def test_should_reject_group_when_multiple_others_mentioned_without_bot() -> None:
|
||
assert (
|
||
should_process_group_inbound(
|
||
is_group=True,
|
||
text="@alice @bob 开会",
|
||
mentions=[
|
||
"111111111111@lid",
|
||
"222222222222@lid",
|
||
],
|
||
bot_jid="6281284654304@s.whatsapp.net",
|
||
require_mention=True,
|
||
metadata={"bot_lid": "176944565977182:2@lid"},
|
||
)
|
||
is False
|
||
)
|
||
|
||
|
||
def test_should_accept_group_when_bot_among_multiple_mentions() -> None:
|
||
assert (
|
||
should_process_group_inbound(
|
||
is_group=True,
|
||
text="@alice @bot 帮忙",
|
||
mentions=[
|
||
"111111111111@lid",
|
||
"176944565977182@lid",
|
||
],
|
||
bot_jid="6281284654304@s.whatsapp.net",
|
||
require_mention=True,
|
||
metadata={"bot_lid": "176944565977182:2@lid"},
|
||
)
|
||
is True
|
||
)
|
||
|
||
|
||
def test_should_reject_group_when_only_other_user_mentioned() -> None:
|
||
assert (
|
||
should_process_group_inbound(
|
||
is_group=True,
|
||
text="@alice hello",
|
||
mentions=["333465375410398@lid"],
|
||
bot_jid="6281284654304@s.whatsapp.net",
|
||
require_mention=True,
|
||
metadata={
|
||
"mentions_bot": True,
|
||
"bot_lid": "176944565977182:2@lid",
|
||
"raw": {"mentionsBot": True, "isReplyToBot": True},
|
||
},
|
||
)
|
||
is False
|
||
)
|
||
|
||
|
||
def test_should_reject_group_when_other_mentioned_even_if_reply_to_bot() -> None:
|
||
assert (
|
||
should_process_group_inbound(
|
||
is_group=True,
|
||
text="@alice follow up",
|
||
mentions=["333465375410398@lid"],
|
||
bot_jid="6281284654304@s.whatsapp.net",
|
||
require_mention=True,
|
||
metadata={
|
||
"bot_lid": "176944565977182:2@lid",
|
||
"raw": {
|
||
"quotedParticipant": "6281284654304@s.whatsapp.net",
|
||
"isReplyToBot": True,
|
||
},
|
||
},
|
||
)
|
||
is False
|
||
)
|
||
|
||
|
||
def test_should_accept_group_reply_to_bot() -> None:
|
||
assert (
|
||
should_process_group_inbound(
|
||
is_group=True,
|
||
text="follow up",
|
||
mentions=[],
|
||
bot_jid="999@s.whatsapp.net",
|
||
require_mention=True,
|
||
metadata={"raw": {"quotedParticipant": "999:0@s.whatsapp.net", "isReplyToBot": True}},
|
||
)
|
||
is True
|
||
)
|
||
|
||
|
||
def test_text_mentions_bot_phone_fallback() -> None:
|
||
assert text_mentions_bot(text="@1234567890 hello", bot_jid="1234567890@s.whatsapp.net") is True
|
||
assert text_mentions_bot(text="hello", bot_jid="1234567890@s.whatsapp.net") is False
|
||
|
||
|
||
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_enrich_quoted_ume_alarm_when_mentioned() -> None:
|
||
from runtime.orchestration.group_ingest import (
|
||
enrich_alert_group_question,
|
||
extract_quoted_ume_alert_text,
|
||
)
|
||
|
||
meta = {"raw": {"quotedText": "[UME Alarm Raised]\nDevice: NE1", "mentionsBot": True}}
|
||
assert extract_quoted_ume_alert_text(metadata=meta).startswith("[UME")
|
||
enriched = enrich_alert_group_question(
|
||
user_text="what happened?",
|
||
quoted_alert="[UME Alarm Raised]\nDevice: NE1",
|
||
)
|
||
assert "[Quoted UME alarm]" in enriched
|
||
assert "what happened?" in enriched
|
||
|
||
|
||
def test_session_user_key_group_scope() -> None:
|
||
assert session_user_key(is_group=True, external_user_id="111@s.whatsapp.net") == "111@s.whatsapp.net"
|
||
assert session_user_key(is_group=True, external_user_id="111@s.whatsapp.net", session_scope="chat") == GROUP_SESSION_USER_SENTINEL
|
||
assert session_user_key(is_group=False, external_user_id="111@s.whatsapp.net") == "111@s.whatsapp.net"
|
||
|
||
|
||
def test_normalize_group_session_scope() -> None:
|
||
assert normalize_group_session_scope("chat") == "chat"
|
||
assert normalize_group_session_scope("shared") == "chat"
|
||
assert normalize_group_session_scope("user_in_chat") == "user_in_chat"
|
||
assert normalize_group_session_scope("") == "user_in_chat"
|
||
|
||
|
||
def test_resolve_group_policy_from_account_config() -> None:
|
||
policy = resolve_group_policy(
|
||
account={
|
||
"config": {
|
||
"group_policy": {
|
||
"require_mention": False,
|
||
"triggers": ["!ask"],
|
||
"session_scope": "chat",
|
||
}
|
||
}
|
||
}
|
||
)
|
||
assert policy.require_mention is False
|
||
assert policy.triggers == ("!ask",)
|
||
assert policy.session_scope == "chat"
|
||
|
||
|
||
def test_infer_is_group_from_chat_id() -> None:
|
||
assert infer_is_group_from_chat_id("120363012345678@g.us") is True
|
||
assert infer_is_group_from_chat_id("111@s.whatsapp.net") is False
|
||
|
||
|
||
def test_parse_generic_inbound_infers_whatsapp_group() -> None:
|
||
inbound = _parse_generic_inbound(
|
||
"whatsapp",
|
||
{
|
||
"user_id": "111@s.whatsapp.net",
|
||
"chat_id": "120363012345678@g.us",
|
||
"text": "hello",
|
||
},
|
||
)
|
||
assert inbound.is_group is True
|
||
|
||
|
||
def test_is_nonsend_channel_reply_text() -> None:
|
||
assert is_nonsend_channel_reply_text("") is True
|
||
assert is_nonsend_channel_reply_text("(silent)") is True
|
||
assert is_nonsend_channel_reply_text("(silent)") is True
|
||
assert is_nonsend_channel_reply_text("NO_REPLY") is True
|
||
assert is_nonsend_channel_reply_text("你好") is False
|
||
|
||
|
||
def test_inbound_group_without_is_group_flag_is_still_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": "Test alarm",
|
||
"metadata": {"bot_jid": "999@s.whatsapp.net", "source": "test"},
|
||
}
|
||
)
|
||
assert out.get("replies") == []
|
||
|
||
|
||
def test_inbound_suppresses_silent_llm_reply(
|
||
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)
|
||
|
||
class _Turn:
|
||
turn_uuid = "turn-s"
|
||
reply_text = "(silent)"
|
||
|
||
class _Gw:
|
||
def __init__(self, *, store: object) -> None:
|
||
_ = store
|
||
|
||
def handle_turn(self, **kwargs: object) -> _Turn:
|
||
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,
|
||
"metadata": {"bot_jid": "999@s.whatsapp.net"},
|
||
}
|
||
)
|
||
assert out.get("replies") == []
|
||
|
||
|
||
def test_build_group_sender_context() -> None:
|
||
ctx = build_group_sender_context(
|
||
metadata={"raw": {"pushName": "Alice"}},
|
||
external_user_id="111@s.whatsapp.net",
|
||
)
|
||
assert ctx == "[Sender: Alice]"
|
||
assert "111@s.whatsapp.net" not in ctx
|
||
zh = build_group_sender_context(
|
||
metadata={"raw": {"pushName": "Alice"}},
|
||
external_user_id="111@s.whatsapp.net",
|
||
lang="zh",
|
||
)
|
||
assert zh == "[发言: Alice]"
|
||
|
||
|
||
def test_strip_bot_mentions_from_text() -> None:
|
||
meta = {"bot_lid": "162788605444170@lid", "bot_push_name": "oliver"}
|
||
out = strip_bot_mentions_from_text(
|
||
text="@162788605444170 生成小鹿照片",
|
||
bot_jid="8618142387786@s.whatsapp.net",
|
||
metadata=meta,
|
||
)
|
||
assert out == "生成小鹿照片"
|
||
|
||
|
||
def test_normalize_mentioned_users_in_text_uses_nickname() -> None:
|
||
out = normalize_mentioned_users_in_text(
|
||
text="每三分钟提醒@200846277140511 喝水",
|
||
mention_jids=["200846277140511@lid"],
|
||
mention_names=["吴华"],
|
||
)
|
||
assert out == "每三分钟提醒@吴华 喝水"
|
||
|
||
|
||
def test_prepare_group_user_text_for_model_user_in_chat() -> None:
|
||
out = prepare_group_user_text_for_model(
|
||
text="@162788605444170 每三分钟提醒@200846277140511 喝水",
|
||
metadata={"bot_lid": "162788605444170@lid", "bot_push_name": "oliver", "raw": {"pushName": "Bob"}},
|
||
mentions=["162788605444170@lid", "200846277140511@lid"],
|
||
bot_jid="8618142387786@s.whatsapp.net",
|
||
session_scope="user_in_chat",
|
||
external_user_id="8618142387786@s.whatsapp.net",
|
||
filtered_mention_jids=["200846277140511@lid"],
|
||
mention_names=["吴华"],
|
||
lang="zh",
|
||
)
|
||
# Per-user group sessions already isolate by session — no sender prefix.
|
||
assert out == "每三分钟提醒@吴华 喝水"
|
||
assert "[发言:" not in out
|
||
assert "[Sender:" not in out
|
||
|
||
|
||
def test_prepare_group_user_text_for_model_shared_chat_prefix() -> None:
|
||
out = prepare_group_user_text_for_model(
|
||
text="@999 帮忙",
|
||
metadata={"raw": {"pushName": "Alice"}},
|
||
mentions=["999@s.whatsapp.net"],
|
||
bot_jid="999@s.whatsapp.net",
|
||
session_scope="chat",
|
||
external_user_id="111@s.whatsapp.net",
|
||
lang="en",
|
||
)
|
||
assert out.startswith("[Sender: Alice]")
|
||
assert out.endswith("帮忙")
|
||
assert "@999" not in out
|
||
|
||
|
||
def test_build_group_focus_instruction() -> None:
|
||
en = build_group_focus_instruction()
|
||
zh = build_group_focus_instruction(lang="zh")
|
||
assert "current sender" in en
|
||
assert "Shared group" in en or "shared" in en.lower()
|
||
assert "群聊规则" in zh
|
||
|
||
|
||
def test_extract_group_quoted_message_and_build_context_block() -> None:
|
||
meta = {
|
||
"raw": {
|
||
"quotedText": "服务器刚刚 502 了",
|
||
"quotedParticipant": "111@s.whatsapp.net",
|
||
"quotedPushName": "Alice",
|
||
"quotedStanzaId": "Q1",
|
||
}
|
||
}
|
||
info = extract_group_quoted_message(metadata=meta)
|
||
assert info["quoted_text"] == "服务器刚刚 502 了"
|
||
assert info["quoted_push_name"] == "Alice"
|
||
block = build_group_quoted_context_block(metadata=meta)
|
||
assert "[Quoted message]" in block
|
||
assert "Alice" in block
|
||
zh_block = build_group_quoted_context_block(metadata=meta, lang="zh")
|
||
assert "[被引用消息]" in zh_block
|
||
|
||
|
||
def test_should_inject_quoted_context_dedupes_recent_message() -> None:
|
||
from svc.persistence.sqlite_store import ChatMessage
|
||
|
||
recent = [
|
||
ChatMessage(
|
||
id=1,
|
||
session_id="s1",
|
||
role="assistant",
|
||
content="server just returned HTTP 502",
|
||
tool_calls=None,
|
||
timestamp="",
|
||
)
|
||
]
|
||
assert should_inject_quoted_context(quoted_text="server just returned HTTP 502", recent_messages=recent) is False
|
||
assert should_inject_quoted_context(quoted_text="a totally different question", recent_messages=recent) is True
|
||
|
||
|
||
def test_should_inject_quoted_context_strips_whatsapp_mention_prefix() -> None:
|
||
from svc.persistence.sqlite_store import ChatMessage
|
||
|
||
body = (
|
||
"### KND-SMNR-EN1-Z20HS — LLDP Neighbors\n\n"
|
||
"| Device | Port | Peer Device |\n"
|
||
"|---|---|---|\n"
|
||
"| KND-SMNR-EN1-Z20HS | cgei-1/1/0/33 | KND-LGGO-EN1-Z20HS |\n"
|
||
"KND-SMNR-EN1 has only 2 LLDP neighbors in the Kendahe area.\n"
|
||
)
|
||
recent = [
|
||
ChatMessage(
|
||
id=10,
|
||
session_id="s1",
|
||
role="assistant",
|
||
content=body,
|
||
tool_calls=None,
|
||
timestamp="",
|
||
event_type="assistant_text",
|
||
)
|
||
]
|
||
# WhatsApp reply-to often prefixes the bot body with @<bot lid/jid>.
|
||
quoted = f"@68458037407987 {body}"
|
||
assert should_inject_quoted_context(quoted_text=quoted, recent_messages=recent) is False
|
||
|
||
|
||
def test_should_inject_quoted_context_finds_assistant_buried_under_tools() -> None:
|
||
from svc.persistence.sqlite_store import ChatMessage
|
||
|
||
body = (
|
||
"KND-SMNR-EN1 has only 2 LLDP neighbors — both access-layer devices "
|
||
"in the Kendahe area connected via cgei ports."
|
||
)
|
||
rows = [
|
||
ChatMessage(
|
||
id=1,
|
||
session_id="s1",
|
||
role="assistant",
|
||
content=body,
|
||
tool_calls=None,
|
||
timestamp="",
|
||
event_type="assistant_text",
|
||
)
|
||
]
|
||
for i in range(2, 20):
|
||
rows.append(
|
||
ChatMessage(
|
||
id=i,
|
||
session_id="s1",
|
||
role="tool",
|
||
content=f'{{"ok": true, "n": {i}}}',
|
||
tool_calls=None,
|
||
timestamp="",
|
||
event_type="tool_result",
|
||
)
|
||
)
|
||
assert should_inject_quoted_context(quoted_text=f"@bot {body}", recent_messages=rows) is False
|
||
assert should_inject_quoted_context(quoted_text="totally new topic xyz about fans", recent_messages=rows) is True
|
||
|
||
|
||
def test_build_whatsapp_group_reply_metadata() -> None:
|
||
inbound = InboundMessage(
|
||
channel="whatsapp",
|
||
external_user_id="111:12@s.whatsapp.net",
|
||
external_chat_id="120363012345678@g.us",
|
||
text="明天几点?",
|
||
is_group=True,
|
||
metadata={
|
||
"raw": {
|
||
"id": "MSG123",
|
||
"participant": "111:12@s.whatsapp.net",
|
||
"pushName": "Alice",
|
||
}
|
||
},
|
||
)
|
||
meta = build_whatsapp_group_reply_metadata(inbound=inbound)
|
||
assert meta["quote_stanza_id"] == "MSG123"
|
||
assert meta["mention_jids"] == ["111:12@s.whatsapp.net"]
|
||
assert meta["quote_text"] == "明天几点?"
|
||
assert meta["quote_participant"] == "111:12@s.whatsapp.net"
|
||
assert meta["mention_names"] == ["Alice"]
|
||
|
||
|
||
def test_default_group_session_is_per_user(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 test_shared_group_session_for_multiple_senders_when_scope_chat(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_scope="chat",
|
||
),
|
||
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_scope="chat",
|
||
),
|
||
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,
|
||
)
|
||
store.upsert_whatsapp_contact(
|
||
tenant_id=tenant_id,
|
||
account_id="wa-default",
|
||
external_user_id=ext_uid,
|
||
phone="".join(ch for ch in ext_uid.split("@", 1)[0] if ch.isdigit()),
|
||
list_type="whitelist",
|
||
)
|
||
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)
|
||
# Keep sync replies so this unit test can assert HTTP response shape.
|
||
monkeypatch.setenv("OCLAW_WHATSAPP_INBOUND_QUEUE_DELIVERY", "0")
|
||
|
||
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_per_user_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": "@999 hi",
|
||
"mentions": ["999@s.whatsapp.net"],
|
||
"metadata": {**base["metadata"], "raw": {"pushName": "Alice"}},
|
||
}
|
||
)
|
||
process_inbound_payload(
|
||
{
|
||
**base,
|
||
"user_id": "222@s.whatsapp.net",
|
||
"text": "@999 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 "[群成员:" not in captured["text"]
|
||
assert "[发言:" not in captured["text"]
|
||
assert "[Sender:" not in captured["text"]
|
||
assert captured["text"] == "again"
|
||
assert "群聊规则" not 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="111@s.whatsapp.net",
|
||
session_title="whatsapp|wa-default+Family",
|
||
)
|
||
assert sid == session_ids[0]
|
||
|
||
_ = tenant_id, user_id
|
||
|
||
|
||
def test_inbound_group_scope_chat_uses_shared_session(
|
||
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"])
|
||
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"],
|
||
"session_scope": "chat",
|
||
}
|
||
},
|
||
is_active=True,
|
||
)
|
||
monkeypatch.setattr("svc.persistence.assistant_store.get_assistant_store", lambda: store)
|
||
|
||
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")
|
||
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]
|
||
|
||
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]
|
||
_ = user_id
|
||
|
||
|
||
def test_inbound_group_injects_quoted_context_when_not_in_current_session(
|
||
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] = {}
|
||
|
||
class _Turn:
|
||
turn_uuid = "turn-q"
|
||
reply_text = "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 "")
|
||
return _Turn()
|
||
|
||
monkeypatch.setattr("runtime.gateway.OclawGateway", _Gw)
|
||
|
||
out = process_inbound_payload(
|
||
{
|
||
"channel": "whatsapp",
|
||
"account_id": "wa-default",
|
||
"user_id": "222@s.whatsapp.net",
|
||
"chat_id": "120363012345678@g.us",
|
||
"text": "@bot 这是不是和刚才发布有关?",
|
||
"is_group": True,
|
||
"mentions": ["999@s.whatsapp.net"],
|
||
"metadata": {
|
||
"bot_jid": "999@s.whatsapp.net",
|
||
"raw": {
|
||
"pushName": "Bob",
|
||
"quotedText": "看起来像 OSPF 邻居抖动",
|
||
"quotedParticipant": "999@s.whatsapp.net",
|
||
"quotedPushName": "oclaw",
|
||
"quotedStanzaId": "Q2",
|
||
},
|
||
},
|
||
}
|
||
)
|
||
assert out.get("ok") is True
|
||
assert "[Quoted message]" in captured["text"]
|
||
assert "看起来像 OSPF 邻居抖动" in captured["text"]
|
||
_ = tenant_id, user_id
|
||
|
||
|
||
def test_inbound_group_skips_quoted_context_when_already_in_current_session(
|
||
monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore
|
||
) -> None:
|
||
store = fresh_sqlite_store
|
||
tenant_id, user_id = _setup_whatsapp_identity(store)
|
||
monkeypatch.setattr("svc.persistence.assistant_store.get_assistant_store", lambda: store)
|
||
|
||
call_no = {"n": 0}
|
||
captured: dict[str, str] = {}
|
||
prior_reply = "Looks like OSPF neighbor flapping"
|
||
|
||
class _Turn:
|
||
turn_uuid = "turn-q2"
|
||
|
||
@property
|
||
def reply_text(self) -> str:
|
||
return prior_reply if call_no["n"] == 1 else "continue analysis"
|
||
|
||
class _Gw:
|
||
def __init__(self, *, store: object) -> None:
|
||
self._store = store
|
||
|
||
def handle_turn(self, **kwargs: object) -> _Turn:
|
||
call_no["n"] += 1
|
||
msg = kwargs.get("msg")
|
||
captured["text"] = str(getattr(msg, "text", "") or "")
|
||
turn = _Turn()
|
||
# Real gateway persists assistant text; mock must do the same so quote
|
||
# dedupe can see the prior reply in session history.
|
||
sid = str(getattr(msg, "session_id", "") or "").strip()
|
||
if sid and turn.reply_text:
|
||
self._store.add_message(
|
||
sid,
|
||
"assistant",
|
||
turn.reply_text,
|
||
event_type="assistant_text",
|
||
)
|
||
return turn
|
||
|
||
monkeypatch.setattr("runtime.gateway.OclawGateway", _Gw)
|
||
|
||
process_inbound_payload(
|
||
{
|
||
"channel": "whatsapp",
|
||
"account_id": "wa-default",
|
||
"user_id": "111@s.whatsapp.net",
|
||
"chat_id": "120363012345678@g.us",
|
||
"text": "@bot please diagnose first",
|
||
"is_group": True,
|
||
"mentions": ["999@s.whatsapp.net"],
|
||
"metadata": {"bot_jid": "999@s.whatsapp.net", "raw": {"pushName": "Alice"}},
|
||
}
|
||
)
|
||
out = process_inbound_payload(
|
||
{
|
||
"channel": "whatsapp",
|
||
"account_id": "wa-default",
|
||
"user_id": "111@s.whatsapp.net",
|
||
"chat_id": "120363012345678@g.us",
|
||
"text": "@bot what is the next check?",
|
||
"is_group": True,
|
||
"mentions": ["999@s.whatsapp.net"],
|
||
"metadata": {
|
||
"bot_jid": "999@s.whatsapp.net",
|
||
"raw": {
|
||
"pushName": "Alice",
|
||
# WhatsApp reply-to often prefixes the bot body with @<bot jid/lid>.
|
||
"quotedText": f"@999 {prior_reply}",
|
||
"quotedParticipant": "999@s.whatsapp.net",
|
||
"quotedPushName": "oclaw",
|
||
"quotedStanzaId": "Q3",
|
||
},
|
||
},
|
||
}
|
||
)
|
||
assert out.get("ok") is True
|
||
assert "[被引用消息]" not in captured["text"]
|
||
assert "[Quoted message]" not in captured["text"]
|
||
_ = tenant_id, user_id
|
||
|
||
|
||
def test_inbound_group_reply_includes_quote_and_mention_metadata(
|
||
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)
|
||
monkeypatch.setenv("OCLAW_WHATSAPP_INBOUND_QUEUE_DELIVERY", "0")
|
||
|
||
class _Turn:
|
||
turn_uuid = "turn-g"
|
||
reply_text = "afternoon three"
|
||
|
||
class _Gw:
|
||
def __init__(self, *, store: object) -> None:
|
||
_ = store
|
||
|
||
def handle_turn(self, **kwargs: object) -> _Turn:
|
||
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": "120363012345678@g.us",
|
||
"text": "@bot tomorrow when?",
|
||
"is_group": True,
|
||
"mentions": ["999@s.whatsapp.net"],
|
||
"metadata": {
|
||
"bot_jid": "999@s.whatsapp.net",
|
||
"raw": {"id": "ABC123", "participant": "111@s.whatsapp.net", "pushName": "Alice"},
|
||
},
|
||
}
|
||
)
|
||
replies = out.get("replies") if isinstance(out.get("replies"), list) else []
|
||
assert replies
|
||
meta = replies[0].get("metadata") if isinstance(replies[0], dict) else {}
|
||
assert isinstance(meta, dict)
|
||
assert meta.get("quote_stanza_id") == "ABC123"
|
||
assert meta.get("mention_jids") == ["111@s.whatsapp.net"]
|
||
assert meta.get("quote_text") == "@bot tomorrow when?"
|
||
|
||
|
||
def test_inbound_group_schedule_mention_metadata_preserved_after_text_clean(
|
||
monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore
|
||
) -> None:
|
||
store = fresh_sqlite_store
|
||
tenant_id, _ = _setup_whatsapp_identity(store)
|
||
store.upsert_whatsapp_contact(
|
||
tenant_id=tenant_id,
|
||
account_id="wa-default",
|
||
external_user_id="200846277140511@lid",
|
||
push_name="WuHua",
|
||
list_type="whitelist",
|
||
)
|
||
monkeypatch.setattr("svc.persistence.assistant_store.get_assistant_store", lambda: store)
|
||
|
||
captured: dict[str, object] = {}
|
||
|
||
class _Turn:
|
||
turn_uuid = "turn-sched"
|
||
reply_text = "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 "")
|
||
captured["metadata"] = getattr(msg, "metadata", None)
|
||
return _Turn()
|
||
|
||
monkeypatch.setattr("runtime.gateway.OclawGateway", _Gw)
|
||
|
||
process_inbound_payload(
|
||
{
|
||
"channel": "whatsapp",
|
||
"account_id": "wa-default",
|
||
"user_id": "111@s.whatsapp.net",
|
||
"chat_id": "120363012345678@g.us",
|
||
"text": "@999 remind @200846277140511 water",
|
||
"is_group": True,
|
||
"mentions": ["999@s.whatsapp.net", "200846277140511@lid"],
|
||
"metadata": {
|
||
"bot_jid": "999@s.whatsapp.net",
|
||
"bot_lid": "162788605444170@lid",
|
||
"raw": {"pushName": "Alice"},
|
||
},
|
||
}
|
||
)
|
||
|
||
meta = captured.get("metadata") if isinstance(captured.get("metadata"), dict) else {}
|
||
assert "200846277140511@lid" in list(meta.get("mentioned_jids") or [])
|
||
assert meta.get("raw_inbound_text")
|
||
text = str(captured.get("text") or "")
|
||
assert "200846277140511" not in text
|
||
assert "@WuHua" in text
|
||
assert "@999" not in text
|
||
|
||
|
||
def test_channel_group_session_visible_in_administrator_chat_list(
|
||
monkeypatch: pytest.MonkeyPatch, fresh_sqlite_store: SqliteStore
|
||
) -> None:
|
||
store = fresh_sqlite_store
|
||
tenant_id, owner_user_id = _setup_whatsapp_identity(store, extra_user_ids=["222@s.whatsapp.net"])
|
||
monkeypatch.setattr("svc.persistence.assistant_store.get_assistant_store", lambda: store)
|
||
|
||
session_ids: list[str] = []
|
||
|
||
class _Turn:
|
||
turn_uuid = "turn-guest"
|
||
reply_text = "ok"
|
||
|
||
class _Gw:
|
||
def __init__(self, *, store: object) -> None:
|
||
_ = store
|
||
|
||
def handle_turn(self, **kwargs: object) -> _Turn:
|
||
msg = kwargs.get("msg")
|
||
session_ids.append(str(getattr(msg, "session_id", "") or ""))
|
||
return _Turn()
|
||
|
||
monkeypatch.setattr("runtime.gateway.OclawGateway", _Gw)
|
||
|
||
process_inbound_payload(
|
||
{
|
||
"channel": "whatsapp",
|
||
"account_id": "wa-default",
|
||
"user_id": "222@s.whatsapp.net",
|
||
"chat_id": "120363012345678@g.us",
|
||
"text": "@999 hello from bob",
|
||
"is_group": True,
|
||
"mentions": ["999@s.whatsapp.net"],
|
||
"metadata": {"bot_jid": "999@s.whatsapp.net", "raw": {"pushName": "Bob"}},
|
||
}
|
||
)
|
||
|
||
assert session_ids
|
||
owner = store.get_ui_session_owner(session_id=session_ids[0]) or {}
|
||
assert owner.get("user_id") == owner_user_id
|
||
|
||
listed = store.list_sessions_for_administrator_chat_view(
|
||
username="administrator",
|
||
tenant_id=tenant_id,
|
||
limit=20,
|
||
offset=0,
|
||
)
|
||
listed_ids = {s.id for s in listed}
|
||
assert session_ids[0] in listed_ids
|