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:
oliver 2026-07-13 20:05:43 +08:00
parent cbe68a54cd
commit ad5d1db3c6
19 changed files with 1507 additions and 58 deletions

View file

@ -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

View 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()

View 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()