321 lines
16 KiB
Python
321 lines
16 KiB
Python
"""成员绑定、持久记录和插件共用接口验证,不连接微信。"""
|
|
import json
|
|
import logging
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, patch
|
|
|
|
from plugins.member_identity import Plugin
|
|
from wxbot_framework.members import MemberError, MemberStore
|
|
from wxbot_framework.sdk import Message, PluginContext, ServiceFacade
|
|
from wxbot_framework.runtime import BotApplication
|
|
from wxbot_framework.config import ConfigStore
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SCRATCH = ROOT / ".runtime/tests"
|
|
ROOM = "12345678@chatroom"
|
|
ROBOT, SMALL, JING = "wxid_robot", "wxid_small", "wxid_jing"
|
|
|
|
|
|
class MemberTests(unittest.TestCase):
|
|
def setUp(self):
|
|
SCRATCH.mkdir(parents=True, exist_ok=True)
|
|
self.temporary = tempfile.TemporaryDirectory(dir=SCRATCH)
|
|
self.root = Path(self.temporary.name).resolve()
|
|
self.assertTrue(self.root.is_relative_to(SCRATCH.resolve()))
|
|
self.options = {"database_file": "data.sqlite3", "history_enabled": True,
|
|
"groups": {ROOM: [
|
|
{"key": "xiaomei", "name": "成员甲", "display_name": "成员甲昵称", "aliases": ["小号"], "wxid": SMALL},
|
|
{"key": "jing", "name": "成员乙", "display_name": "成员乙群名", "aliases": ["成员乙别名"], "wxid": None},
|
|
{"key": "yuting", "name": "成员丁", "display_name": "DemoMemberC", "wxid": None}]}}
|
|
self.config_path = self.root / "members.json"
|
|
self.write_options()
|
|
self.stores = []
|
|
self.store = self.create_store()
|
|
|
|
def write_options(self):
|
|
self.config_path.write_text(json.dumps(self.options, ensure_ascii=False), encoding="utf-8")
|
|
|
|
def create_store(self, account=ROBOT):
|
|
store = MemberStore(self.config_path, account)
|
|
self.stores.append(store)
|
|
return store
|
|
|
|
def tearDown(self):
|
|
for store in self.stores:
|
|
store.close()
|
|
self.assertTrue(self.root.is_relative_to(SCRATCH.resolve()))
|
|
self.temporary.cleanup()
|
|
|
|
def message(self, text="中文消息 🌸", sender=JING, sequence=1, **kwargs):
|
|
return Message(text, ROOM, sender_id=sender, is_group=True, sequence=sequence, session_id="first", **kwargs)
|
|
|
|
def test_configuration_check_does_not_create_database(self):
|
|
self.assertFalse(self.store.path.exists())
|
|
self.store.initialize()
|
|
self.assertTrue(self.store.path.exists())
|
|
|
|
def test_aliases_are_resolved_case_insensitively_and_small_is_bound(self):
|
|
self.assertEqual(self.store.resolve("小号").wxid, SMALL)
|
|
self.assertEqual(self.store.resolve("成员乙群名", ROOM).name, "成员乙")
|
|
self.assertEqual(self.store.resolve("demomemberc", ROOM).name, "成员丁")
|
|
self.assertIsNone(self.store.resolve("成员乙").wxid)
|
|
|
|
def test_unknown_names_are_not_guessed(self):
|
|
with self.assertRaises(MemberError):
|
|
self.store.resolve("未登记的昵称")
|
|
with self.assertRaises(MemberError):
|
|
self.store.bind("未登记的昵称", ROOM, JING)
|
|
|
|
def test_binding_survives_restart_and_null_profile_does_not_clear_it(self):
|
|
self.store.bind("成员乙群名", ROOM, JING)
|
|
self.store.close()
|
|
reopened = self.create_store()
|
|
self.assertEqual(reopened.resolve("成员乙", ROOM).wxid, JING)
|
|
|
|
def test_conflicting_binding_does_not_replace_identity(self):
|
|
self.store.bind("成员乙", ROOM, JING)
|
|
self.assertEqual(self.store.bind("成员乙", ROOM, JING).wxid, JING)
|
|
with self.assertRaises(MemberError):
|
|
self.store.bind("成员乙", ROOM, "wxid_other")
|
|
with self.assertRaises(MemberError):
|
|
self.store.bind("成员丁", ROOM, JING)
|
|
self.assertEqual(self.store.resolve("成员乙", ROOM).wxid, JING)
|
|
|
|
def test_history_before_binding_becomes_queryable_by_alias(self):
|
|
self.store.record(self.message())
|
|
with self.assertRaises(MemberError):
|
|
self.store.history("成员乙")
|
|
self.store.bind("成员乙", ROOM, JING)
|
|
rows = self.store.history("成员乙别名")
|
|
self.assertEqual([row["text"] for row in rows], ["中文消息 🌸"])
|
|
self.assertEqual(rows[0]["sender_id"], JING)
|
|
self.assertEqual(datetime.fromisoformat(rows[0]["received_at"]).utcoffset(), timedelta(0))
|
|
|
|
def test_history_is_isolated_by_robot_account_and_author(self):
|
|
self.store.bind("成员乙", ROOM, JING)
|
|
self.store.record(self.message("成员乙的话"))
|
|
self.store.record(self.message("成员甲的话", sender=SMALL, sequence=2))
|
|
second = self.create_store("wxid_second_robot")
|
|
second.bind("成员乙", ROOM, JING)
|
|
self.assertEqual(second.history("成员乙"), [])
|
|
self.assertEqual([row["text"] for row in self.store.history("成员乙")], ["成员乙的话"])
|
|
|
|
def test_private_other_group_and_wrong_account_are_not_archived(self):
|
|
self.assertFalse(self.store.record(Message("私聊", JING, sender_id=JING, session_id="first")))
|
|
self.assertFalse(self.store.record(Message("另一群", "99999@chatroom", sender_id=JING, is_group=True, session_id="first")))
|
|
self.assertFalse(self.store.record(self.message(data={"self_wxid": "wxid_other_robot"})))
|
|
|
|
def test_sequence_deduplication_and_new_session(self):
|
|
self.assertTrue(self.store.record(self.message()))
|
|
self.assertFalse(self.store.record(self.message()))
|
|
self.assertTrue(self.store.record(Message("重连消息", ROOM, JING, is_group=True, sequence=1, session_id="second")))
|
|
self.store.bind("成员乙", ROOM, JING)
|
|
self.assertEqual(len(self.store.history("成员乙")), 2)
|
|
|
|
def test_limit_and_time_filter_return_chronological_records(self):
|
|
self.store.bind("成员乙", ROOM, JING)
|
|
self.store.record(self.message("第一句", sequence=1))
|
|
since = datetime.now(timezone.utc)
|
|
self.store.record(self.message("第二句", sequence=2))
|
|
self.assertEqual([r["text"] for r in self.store.history("成员乙", limit=1)], ["第二句"])
|
|
self.assertEqual([r["text"] for r in self.store.history("成员乙", since=since)], ["第二句"])
|
|
self.assertEqual([r["text"] for r in self.store.history("成员乙")], ["第一句", "第二句"])
|
|
for limit in (True, 0, 1001):
|
|
with self.assertRaises(MemberError):
|
|
self.store.history("成员乙", limit=limit)
|
|
with self.assertRaises(MemberError):
|
|
self.store.history("成员乙", since=datetime.now())
|
|
|
|
def test_disabled_history_keeps_member_directory(self):
|
|
self.options["history_enabled"] = False
|
|
self.write_options()
|
|
disabled = self.create_store()
|
|
self.assertEqual(disabled.resolve("成员甲").wxid, SMALL)
|
|
self.assertFalse(disabled.record(self.message()))
|
|
|
|
def test_same_alias_in_two_groups_requires_group(self):
|
|
second_room = "99999@chatroom"
|
|
self.options["groups"][second_room] = self.options["groups"][ROOM]
|
|
self.write_options()
|
|
multi = self.create_store()
|
|
with self.assertRaises(MemberError):
|
|
multi.resolve("成员乙")
|
|
self.assertEqual(multi.resolve("成员乙", second_room).room, second_room)
|
|
|
|
def test_configuration_rejects_duplicate_casefolded_aliases(self):
|
|
self.options["groups"][ROOM][1]["aliases"] = ["demomemberc"]
|
|
self.write_options()
|
|
with self.assertRaises(MemberError):
|
|
self.create_store()
|
|
|
|
def test_recording_and_lookup_from_different_threads(self):
|
|
self.store.bind("成员乙", ROOM, JING)
|
|
errors = []
|
|
def write(index):
|
|
try:
|
|
self.store.record(self.message(str(index), sequence=index))
|
|
self.store.resolve("成员乙")
|
|
except Exception as error:
|
|
errors.append(error)
|
|
threads = [threading.Thread(target=write, args=(index,)) for index in range(1, 13)]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join(3)
|
|
self.assertEqual(errors, [])
|
|
self.assertEqual(len(self.store.history("成员乙")), 12)
|
|
|
|
def test_identity_plugin_binds_actual_message_author(self):
|
|
ctx = Mock(members=self.store, logger=logging.getLogger("MembersTest"))
|
|
plugin = Plugin()
|
|
plugin.on_load(ctx)
|
|
plugin.on_message(self.message("/绑定 成员乙"), ctx)
|
|
self.assertEqual(self.store.resolve("成员乙").wxid, JING)
|
|
ctx.reply.assert_called_once_with(unittest.mock.ANY, "已绑定:成员乙。")
|
|
plugin.on_message(self.message("/绑定 成员丁"), ctx)
|
|
self.assertIsNone(self.store.resolve("成员丁").wxid)
|
|
|
|
def test_context_auto_mention_uses_group_name_instead_of_personal_alias(self):
|
|
self.store.bind("成员乙", ROOM, JING)
|
|
backend = Mock(session_id="first")
|
|
service = ServiceFacade(text_delay={"enabled": False})
|
|
service.bind(backend)
|
|
try:
|
|
ctx = PluginContext("example", {}, service, [], member_store=self.store)
|
|
self.assertEqual(ctx.members.resolve("成员乙").wxid, JING)
|
|
ctx.reply_at(self.message(), "回复")
|
|
backend.send_at_text.assert_called_once_with(ROOM, "@成员乙群名\u2005\n回复", (JING,))
|
|
finally:
|
|
service.close()
|
|
|
|
def test_runtime_archives_before_plugin_dispatch(self):
|
|
(self.root / "plugins").mkdir()
|
|
path = self.root / "bot_config.json"
|
|
path.write_text(json.dumps({"backend": "native_text", "wechat_account": ROBOT,
|
|
"members_config_file": self.config_path.name}), encoding="utf-8")
|
|
app = BotApplication(self.root, ConfigStore(path))
|
|
try:
|
|
app.members.bind("成员乙", ROOM, JING)
|
|
def verify(message):
|
|
self.assertEqual(app.members.history("成员乙")[0]["text"], message.text)
|
|
with patch.object(app.plugins, "dispatch", side_effect=verify):
|
|
app._emit(self.message())
|
|
finally:
|
|
app.stop()
|
|
|
|
def test_free_registration_accepts_new_name_and_links_existing_history(self):
|
|
self.store.record(self.message("绑定前说过的话"))
|
|
member = self.store.register("测试成员", ROOM, JING)
|
|
self.assertEqual(member.wxid, JING)
|
|
self.assertEqual(self.store.history("测试成员")[0]["text"], "绑定前说过的话")
|
|
|
|
def test_same_account_can_rename_without_losing_aliases_or_history(self):
|
|
self.store.register("测试成员", ROOM, JING)
|
|
self.store.record(self.message("原有记录"))
|
|
self.store.register("成员丙", ROOM, JING)
|
|
self.assertEqual(self.store.resolve("测试成员").name, "成员丙")
|
|
self.assertEqual(self.store.resolve("成员丙").wxid, JING)
|
|
self.assertEqual(self.store.resolve("成员丙").display_name, "成员丙")
|
|
self.assertEqual(self.store.history("成员丙")[0]["text"], "原有记录")
|
|
self.assertEqual(len([m for m in self.store.list_members() if m.wxid == JING]), 1)
|
|
|
|
def test_another_account_cannot_take_existing_nickname(self):
|
|
self.store.register("测试成员", ROOM, JING)
|
|
with self.assertRaises(MemberError):
|
|
self.store.register("测试成员", ROOM, SMALL)
|
|
self.assertEqual(self.store.resolve("测试成员").wxid, JING)
|
|
|
|
def test_registered_name_survives_config_seeding_on_restart(self):
|
|
self.store.register("成员乙", ROOM, JING)
|
|
self.store.register("测试成员", ROOM, JING)
|
|
self.store.close()
|
|
reopened = self.create_store()
|
|
self.assertEqual(reopened.resolve("成员乙").name, "测试成员")
|
|
self.assertEqual(reopened.resolve("测试成员").wxid, JING)
|
|
|
|
def test_free_registration_still_limits_groups_and_name_shape(self):
|
|
with self.assertRaises(MemberError):
|
|
self.store.register("测试成员", "99999@chatroom", JING)
|
|
for name in ("", "x" * 65, "喵\n喵", "喵\0喵"):
|
|
with self.subTest(name=name), self.assertRaises(MemberError):
|
|
self.store.register(name, ROOM, JING)
|
|
|
|
def test_group_binding_overrides_legacy_display_and_survives_restart(self):
|
|
self.store.register("成员甲", ROOM, SMALL)
|
|
self.assertEqual(self.store.display_name(SMALL, ROOM), "成员甲")
|
|
self.store.close()
|
|
self.store = self.create_store()
|
|
self.assertEqual(self.store.display_name(SMALL, ROOM), "成员甲")
|
|
self.assertTrue(self.store.resolve("成员甲", ROOM).self_named)
|
|
|
|
def test_wechat_default_and_bindings_are_separate_in_each_group(self):
|
|
other_room = "87654321@chatroom"
|
|
self.options["groups"][other_room] = []
|
|
self.write_options()
|
|
store = self.create_store()
|
|
store.register("成员甲", ROOM, SMALL)
|
|
store.remember_wechat_name(SMALL, "DemoUser")
|
|
message = Message("新群的原话", other_room, SMALL, is_group=True, session_id="new", sequence=1)
|
|
store.record(message)
|
|
self.assertEqual(store.display_name(SMALL, other_room), "DemoUser")
|
|
self.assertEqual(store.list_members(other_room), [])
|
|
self.assertEqual(store.today_snapshot("DemoUser", other_room).member.wxid, SMALL)
|
|
backend = Mock(session_id="new")
|
|
service = ServiceFacade(text_delay={"enabled": False})
|
|
service.bind(backend)
|
|
try:
|
|
ctx = PluginContext("test", {}, service, [], member_store=store)
|
|
ctx.reply_at(message, "默认回复")
|
|
self.assertEqual(backend.send_at_text.call_args.args[1], "@DemoUser\u2005\n默认回复")
|
|
store.register("新群名字", other_room, SMALL)
|
|
ctx.reply_at(message, "已绑定", display_name="旧的硬编码名字")
|
|
self.assertEqual(backend.send_at_text.call_args.args[1], "@新群名字\u2005\n已绑定")
|
|
self.assertEqual(store.display_name(SMALL, ROOM), "成员甲")
|
|
self.assertEqual(store.today_snapshot("新群名字", other_room).messages[0]["text"], "新群的原话")
|
|
finally:
|
|
service.close()
|
|
|
|
def test_native_wechat_name_updates_default_without_overwriting_group_binding(self):
|
|
other_room = "87654321@chatroom"
|
|
self.options["groups"][other_room] = []
|
|
self.write_options()
|
|
store = self.create_store()
|
|
store.register("成员甲", ROOM, SMALL)
|
|
for sequence, name in ((1, "DemoUser"), (2, "新的微信名")):
|
|
msg = Message("原文", other_room, SMALL, is_group=True, session_id="new", sequence=sequence,
|
|
data={"sender_wechat_name": name, "self_wxid": ROBOT})
|
|
store.record(msg)
|
|
self.assertEqual(store.display_name(SMALL, other_room), name)
|
|
self.assertEqual(store.display_name(SMALL, ROOM), "成员甲")
|
|
self.assertEqual(store.today_group_snapshot(other_room).messages[-1]["speaker_name"], "新的微信名")
|
|
self.assertEqual(store.conversation_snapshot(msg).speaker_name, "新的微信名")
|
|
store.clear(other_room)
|
|
self.assertEqual(store.today_snapshot("新的微信名", other_room).messages, ())
|
|
|
|
def test_duplicate_default_wechat_names_do_not_mix_member_identities(self):
|
|
self.options["groups"][ROOM] = []
|
|
self.write_options()
|
|
store = self.create_store()
|
|
for index, wxid in enumerate((SMALL, JING), 1):
|
|
store.record(self.message("不同成员", sender=wxid, sequence=index,
|
|
data={"sender_wechat_name": "同名"}))
|
|
with self.assertRaisesRegex(MemberError, "多个成员"):
|
|
store.today_snapshot("同名", ROOM)
|
|
store.register("指定成员", ROOM, SMALL)
|
|
self.assertEqual(store.today_snapshot("指定成员", ROOM).member.wxid, SMALL)
|
|
|
|
def test_plugin_free_registration_and_usage(self):
|
|
ctx = Mock(members=self.store, logger=logging.getLogger("MembersTest"))
|
|
plugin = Plugin()
|
|
plugin.on_load(ctx)
|
|
plugin.on_message(self.message("/绑定 测试成员"), ctx)
|
|
self.assertEqual(self.store.resolve("测试成员").wxid, JING)
|
|
plugin.on_message(self.message("/绑定"), ctx)
|
|
self.assertEqual(ctx.reply.call_args.args[1], "用法:/绑定 昵称")
|
|
plugin.on_message(self.message(" /绑定\t成员丙 "), ctx)
|
|
self.assertEqual(self.store.resolve("成员丙").wxid, JING)
|