mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 00:43:18 +08:00
feat(whatsapp): real group @mentions and cleaner inbound text
Wire mention JIDs/names through scheduled delivery and immediate replies (including attachment captions), sanitize group message text for the model, and surface channel-derived sessions in admin Chat. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cbe68a54cd
commit
ad5d1db3c6
19 changed files with 1507 additions and 58 deletions
|
|
@ -13,10 +13,13 @@ from runtime.orchestration.group_ingest import (
|
|||
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
|
||||
|
|
@ -356,8 +359,56 @@ def test_build_group_sender_context() -> None:
|
|||
metadata={"raw": {"pushName": "Alice"}},
|
||||
external_user_id="111@s.whatsapp.net",
|
||||
)
|
||||
assert "Alice" in ctx
|
||||
assert "111@s.whatsapp.net" in ctx
|
||||
assert ctx == "[发言: Alice]"
|
||||
assert "111@s.whatsapp.net" not in ctx
|
||||
|
||||
|
||||
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"},
|
||||
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=["吴华"],
|
||||
)
|
||||
assert out == "每三分钟提醒@吴华 喝水"
|
||||
assert "[发言:" 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",
|
||||
)
|
||||
assert out.startswith("[发言: Alice]")
|
||||
assert out.endswith("帮忙")
|
||||
assert "@999" not in out
|
||||
|
||||
|
||||
def test_build_group_focus_instruction() -> None:
|
||||
|
|
@ -421,6 +472,7 @@ def test_build_whatsapp_group_reply_metadata() -> None:
|
|||
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:
|
||||
|
|
@ -608,7 +660,7 @@ def test_inbound_group_mention_uses_per_user_session_and_sender_prefix(
|
|||
{
|
||||
**base,
|
||||
"user_id": "111@s.whatsapp.net",
|
||||
"text": "@bot hi",
|
||||
"text": "@999 hi",
|
||||
"mentions": ["999@s.whatsapp.net"],
|
||||
"metadata": {**base["metadata"], "raw": {"pushName": "Alice"}},
|
||||
}
|
||||
|
|
@ -617,7 +669,7 @@ def test_inbound_group_mention_uses_per_user_session_and_sender_prefix(
|
|||
{
|
||||
**base,
|
||||
"user_id": "222@s.whatsapp.net",
|
||||
"text": "@bot again",
|
||||
"text": "@999 again",
|
||||
"mentions": ["999@s.whatsapp.net"],
|
||||
"metadata": {**base["metadata"], "raw": {"pushName": "Bob"}},
|
||||
}
|
||||
|
|
@ -625,9 +677,10 @@ def test_inbound_group_mention_uses_per_user_session_and_sender_prefix(
|
|||
|
||||
assert len(session_ids) == 2
|
||||
assert session_ids[0] != session_ids[1]
|
||||
assert "[群成员:" in captured["text"]
|
||||
assert "群聊规则" in captured["text"]
|
||||
assert "Bob" in captured["text"]
|
||||
assert "[群成员:" not in captured["text"]
|
||||
assert "[发言:" 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,
|
||||
|
|
@ -882,3 +935,112 @@ def test_inbound_group_reply_includes_quote_and_mention_metadata(
|
|||
assert meta.get("quote_stanza_id") == "ABC123"
|
||||
assert meta.get("mention_jids") == ["111@s.whatsapp.net"]
|
||||
assert meta.get("quote_text") == "@bot 明天几点?"
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
74
tests/test_whatsapp_mention_jid_field.py
Normal file
74
tests/test_whatsapp_mention_jid_field.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from runtime.scheduler.whatsapp_mentions import format_whatsapp_mention_text, merge_whatsapp_mention_jids, normalize_whatsapp_mention_jids
|
||||
from svc.persistence.assistant_store import reset_assistant_store_singleton
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class WhatsappMentionJidFieldTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "wa_names.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
|
||||
reset_assistant_store_singleton()
|
||||
self.store = SqliteStore(str(self.db))
|
||||
tenant = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(tenant["id"])
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_assistant_store_singleton()
|
||||
self._tmp.cleanup()
|
||||
def test_normalize_whatsapp_mention_jids(self) -> None:
|
||||
out = normalize_whatsapp_mention_jids(
|
||||
["6281@s.whatsapp.net", "6282@s.whatsapp.net, 6283@s.whatsapp.net"]
|
||||
)
|
||||
self.assertEqual(
|
||||
out,
|
||||
["6281@s.whatsapp.net", "6282@s.whatsapp.net", "6283@s.whatsapp.net"],
|
||||
)
|
||||
|
||||
def test_merge_whatsapp_mention_jids_into_delivery(self) -> None:
|
||||
delivery = merge_whatsapp_mention_jids(
|
||||
{"whatsapp": {"enabled": True, "chat_id": "120@g.us"}},
|
||||
["6281@s.whatsapp.net"],
|
||||
)
|
||||
self.assertEqual(delivery["whatsapp"]["mention_jids"], ["6281@s.whatsapp.net"])
|
||||
|
||||
def test_format_whatsapp_mention_text_uses_push_name(self) -> None:
|
||||
out = format_whatsapp_mention_text(
|
||||
"💧 @+1 33346537541839 该喝水啦",
|
||||
["333465375410398@lid"],
|
||||
store=self.store,
|
||||
tenant_id=self.tenant_id,
|
||||
account_id="wa-default",
|
||||
mention_names=["Egista Hadi Putranto"],
|
||||
)
|
||||
self.assertEqual(out, "@Egista Hadi Putranto 💧 该喝水啦")
|
||||
|
||||
def test_format_whatsapp_mention_text_looks_up_push_name_by_jid(self) -> None:
|
||||
self.store.upsert_whatsapp_contact(
|
||||
tenant_id=self.tenant_id,
|
||||
account_id="wa-default",
|
||||
external_user_id="333465375410398@lid",
|
||||
push_name="Egista Hadi Putranto",
|
||||
phone="33346537541839",
|
||||
list_type="whitelist",
|
||||
)
|
||||
out = format_whatsapp_mention_text(
|
||||
"该喝水啦",
|
||||
["333465375410398@lid"],
|
||||
store=self.store,
|
||||
tenant_id=self.tenant_id,
|
||||
account_id="wa-default",
|
||||
)
|
||||
self.assertEqual(out, "@Egista Hadi Putranto 该喝水啦")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
116
tests/test_whatsapp_scheduled_mentions.py
Normal file
116
tests/test_whatsapp_scheduled_mentions.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from runtime.scheduler.channel_delivery import deliver_scheduled_reply
|
||||
from svc.persistence.assistant_store import reset_assistant_store_singleton
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class WhatsappScheduledMentionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "wa_mentions.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
|
||||
reset_assistant_store_singleton()
|
||||
self.store = SqliteStore(str(self.db))
|
||||
tenant = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(tenant["id"])
|
||||
self.store.upsert_whatsapp_contact(
|
||||
tenant_id=self.tenant_id,
|
||||
account_id="wa-default",
|
||||
external_user_id="333465375410398@lid",
|
||||
push_name="Egista Hadi Putranto",
|
||||
phone="33346537541839",
|
||||
list_type="whitelist",
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_assistant_store_singleton()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_deliver_scheduled_reply_encodes_explicit_mention_jids(self) -> None:
|
||||
delivery = {
|
||||
"whatsapp": {
|
||||
"enabled": True,
|
||||
"target_type": "group",
|
||||
"chat_id": "120363012345678@g.us",
|
||||
"account_id": "wa-default",
|
||||
"mention_jids": ["333465375410398@lid"],
|
||||
},
|
||||
"weixin": {"enabled": False},
|
||||
}
|
||||
reply_text = "💧 @+1 33346537541839 该喝水啦!记得保持水分哦~"
|
||||
result = deliver_scheduled_reply(
|
||||
self.store,
|
||||
tenant_id=self.tenant_id,
|
||||
reply_text=reply_text,
|
||||
delivery_json=__import__("json").dumps(delivery),
|
||||
)
|
||||
self.assertTrue(result.get("ok"), result)
|
||||
pending = self.store.list_pending_channel_outbound_messages(
|
||||
channel="whatsapp",
|
||||
account_id="wa-default",
|
||||
limit=5,
|
||||
)
|
||||
self.assertEqual(len(pending), 1)
|
||||
source = __import__("json").loads(str(pending[0].get("source") or "{}"))
|
||||
self.assertEqual(source.get("mention_jids"), ["333465375410398@lid"])
|
||||
out_text = str(pending[0].get("text") or "")
|
||||
self.assertTrue(out_text.startswith("@Egista Hadi Putranto"))
|
||||
self.assertIn("该喝水啦", out_text)
|
||||
self.assertNotIn("@+1", out_text)
|
||||
self.assertTrue(source.get("mention_text_ready"))
|
||||
self.assertEqual(source.get("mention_names"), ["Egista Hadi Putranto"])
|
||||
|
||||
def test_deliver_scheduled_reply_infers_mentions_from_phoneish_text(self) -> None:
|
||||
self.store.upsert_whatsapp_contact(
|
||||
tenant_id=self.tenant_id,
|
||||
account_id="wa-default",
|
||||
external_user_id="200846277140511@lid",
|
||||
push_name="吴华",
|
||||
phone="0846277140511",
|
||||
list_type="whitelist",
|
||||
)
|
||||
delivery = {
|
||||
"whatsapp": {
|
||||
"enabled": True,
|
||||
"target_type": "group",
|
||||
"chat_id": "120363012345678@g.us",
|
||||
"account_id": "wa-default",
|
||||
},
|
||||
"weixin": {"enabled": False},
|
||||
}
|
||||
reply_text = "💧 @+20 0846277140511 该喝水啦!快去补充一下水分吧~ 💧"
|
||||
result = deliver_scheduled_reply(
|
||||
self.store,
|
||||
tenant_id=self.tenant_id,
|
||||
reply_text=reply_text,
|
||||
delivery_json=__import__("json").dumps(delivery),
|
||||
)
|
||||
self.assertTrue(result.get("ok"), result)
|
||||
pending = self.store.list_pending_channel_outbound_messages(
|
||||
channel="whatsapp",
|
||||
account_id="wa-default",
|
||||
limit=5,
|
||||
)
|
||||
self.assertEqual(len(pending), 1)
|
||||
source = __import__("json").loads(str(pending[0].get("source") or "{}"))
|
||||
self.assertEqual(source.get("mention_jids"), ["200846277140511@lid"])
|
||||
out_text = str(pending[0].get("text") or "")
|
||||
self.assertTrue(out_text.startswith("@吴华"))
|
||||
self.assertIn("该喝水啦", out_text)
|
||||
self.assertNotIn("@+20", out_text)
|
||||
|
||||
def test_normalize_bare_digits_to_lid(self) -> None:
|
||||
from runtime.scheduler.whatsapp_mentions import normalize_whatsapp_mention_jids
|
||||
|
||||
self.assertEqual(normalize_whatsapp_mention_jids(["200846277140511"]), ["200846277140511@lid"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue