"""不连接微信的框架行为测试;发送结果由内存后端记录。""" from __future__ import annotations import json import logging import os from pathlib import Path import shutil import sys import tempfile import threading import time import unittest import uuid from wxbot_framework.backend import CAPABILITIES from wxbot_framework.config import ConfigStore from wxbot_framework.locking import InstanceLock from wxbot_framework.plugins import PluginManager from wxbot_framework.runtime import BotApplication from wxbot_framework.sdk import Message, PluginContext, ServiceFacade ROOT = Path(__file__).resolve().parent.parent SCRATCH = ROOT / ".runtime/tests" def wait_for(condition, seconds=3): deadline = time.monotonic() + seconds while time.monotonic() < deadline: if condition(): return time.sleep(0.01) raise AssertionError("等待异步结果超时") class MemoryBackend: def __init__(self, fail_start=False): self.session_id = uuid.uuid4().hex self.pid = 123 self.last_error = None self.sent = [] self.fail_start = fail_start self.started = False self.stops = 0 def start(self, emit): if self.fail_start: raise RuntimeError("模拟微信尚未启动") self.emit = emit self.started = True return True def send_text(self, chat_id, text): self.sent.append((chat_id, text)) return True def stop(self): self.stops += 1 self.started = False return {"dll_unloaded": True} class FrameworkTests(unittest.TestCase): def setUp(self): self.previous_logging = logging.root.manager.disable logging.disable(logging.CRITICAL) 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())) shutil.copytree(ROOT / "plugins", self.root / "plugins") self.path = self.root / "bot_config.json" self.config = json.loads((ROOT / "bot_config.json").read_text(encoding="utf-8")) self.config.update(console=False, reconnect_seconds=0.1, text_send_delay={"enabled": False}) # 框架测试不调用外部大模型;玩法单独用假客户端验证。 self.config["plugins"]["idiom_chain"]["enabled"] = False self.config["plugins"]["member_identity"]["enabled"] = False self.config["plugins"]["chat_summary"]["enabled"] = False self.config["plugins"]["chat_records"]["enabled"] = False self.config["plugins"]["personality"]["enabled"] = False self.config["plugins"]["mention_chat"]["enabled"] = False self.config["members_config_file"] = None self.write_config() self.store = ConfigStore(self.path) self.backend = MemoryBackend() self.service = ServiceFacade(text_delay=self.config["text_send_delay"]) self.service.bind(self.backend) self.manager = PluginManager(self.root / "plugins", self.store, self.service, CAPABILITIES) def tearDown(self): self.manager.shutdown() self.service.close() # 递归清理只针对本测试创建、已核对位于工作区的临时目录。 self.assertTrue(self.root.resolve().is_relative_to(SCRATCH.resolve())) self.temporary.cleanup() logging.disable(self.previous_logging) def write_config(self): self.path.write_text(json.dumps(self.config, ensure_ascii=False), encoding="utf-8") def create_plugin(self, name, source, *, manifest=None): directory = self.root / "plugins" / name directory.mkdir(exist_ok=True) (directory / "__init__.py").write_text(source, encoding="utf-8") if manifest: (directory / "plugin.json").write_text(json.dumps(manifest), encoding="utf-8") return directory / "__init__.py" def message(self, text, sequence=1): return Message.from_native(11046, {"msg": text, "from_wxid": "filehelper", "room_wxid": "", "native_sender": "self_test", "native_receiver": "filehelper", "sequence": sequence}, self.backend.session_id) def test_new_and_legacy_plugins_receive_unicode_snapshot(self): self.manager.reload() source = {"msg": "echo 中文 🌸\n第二行", "from_wxid": "filehelper"} message = Message.from_native(11046, source, self.backend.session_id) source["msg"] = "已被上游修改" self.manager.dispatch(message) self.manager.dispatch(self.message("test", 2)) wait_for(lambda: len(self.backend.sent) == 2) self.assertCountEqual(self.backend.sent, [("filehelper", "中文 🌸\n第二行"), ("filehelper", "filehelper")]) def test_plugin_failure_does_not_stop_other_plugins_or_next_message(self): self.create_plugin("broken", "class Plugin:\n def on_message(self,m,c):\n raise SystemExit('模拟插件错误')\n") self.manager.reload() self.manager.dispatch(self.message("test")) self.manager.dispatch(self.message("echo 好的", 2)) wait_for(lambda: len(self.backend.sent) == 2 and self.manager.records["broken"].errors == 2) self.assertTrue(self.manager.records["broken"].thread.is_alive()) self.assertIn(("filehelper", "好的"), self.backend.sent) def test_disabled_and_unsupported_plugins_are_not_imported(self): source = "raise RuntimeError('此插件不应被导入')\n" self.create_plugin("disabled", source) self.create_plugin("groups", source, manifest={"api": "v1", "requires": ["group.at"]}) self.config["plugins"]["disabled"] = {"enabled": False} self.write_config() self.manager.reload() self.assertNotIn("disabled", self.manager.records) self.assertNotIn("groups", self.manager.records) groups = next(row for row in self.manager.status() if row["name"] == "groups") self.assertEqual(groups["missing_capabilities"], ["group.at"]) def test_enable_disable_persist_and_keep_other_plugins_running(self): self.manager.reload() self.manager.disable("echo") saved = self.store.load()["plugins"]["echo"] self.assertFalse(saved["enabled"]) self.assertEqual(saved["prefix"], "echo ") self.manager.dispatch(self.message("echo 不应回复")) self.manager.dispatch(self.message("test", 2)) wait_for(lambda: self.manager.records["id_responder"].handled == 2) self.assertEqual(self.backend.sent, [("filehelper", "filehelper")]) self.manager.enable("echo") self.assertTrue(self.store.load()["plugins"]["echo"]["enabled"]) self.manager.dispatch(self.message("echo 恢复了", 3)) wait_for(lambda: ("filehelper", "恢复了") in self.backend.sent) def test_reload_uses_fresh_source_and_failed_reload_keeps_previous(self): source = "class Plugin:\n def on_message(self,m,c):\n c.reply(m,'v1')\n" path = self.create_plugin("reloadable", source) self.manager.reload() self.manager.dispatch(self.message("one")) wait_for(lambda: ("filehelper", "v1") in self.backend.sent) previous_module = self.manager.records["reloadable"].module_name stat = path.stat() path.write_text(source.replace("v1", "v2"), encoding="utf-8") os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns)) self.manager.reload("reloadable") self.assertNotIn(previous_module, sys.modules) self.manager.dispatch(self.message("two", 2)) wait_for(lambda: ("filehelper", "v2") in self.backend.sent) current = self.manager.records["reloadable"] path.write_text("class Plugin(:\n", encoding="utf-8") self.manager.reload("reloadable") self.assertIs(self.manager.records["reloadable"], current) self.assertTrue(current.active) self.manager.dispatch(self.message("three", 3)) wait_for(lambda: self.backend.sent.count(("filehelper", "v2")) == 2) def test_unload_releases_plugin_resources(self): marker = self.root / "lifecycle.txt" source = ("class Plugin:\n" " def on_load(self,c):\n self.file=open(c.config['marker'],'w',encoding='utf-8')\n" " def on_message(self,m,c):\n pass\n" " def on_unload(self,c):\n self.file.write('released');self.file.close()\n") self.create_plugin("resource", source) self.config["plugins"]["resource"] = {"enabled": True, "marker": str(marker)} self.write_config() self.manager.reload() self.manager.disable("resource") self.assertEqual(marker.read_text(encoding="utf-8"), "released") def test_busy_reload_does_not_replace_active_callback(self): source = ("import threading\nclass Plugin:\n" " def on_load(self,c):\n self.entered=threading.Event();self.release=threading.Event()\n" " def on_message(self,m,c):\n self.entered.set();self.release.wait(4)\n") self.create_plugin("busy", source) self.manager.reload() original = self.manager.records["busy"] self.manager.dispatch(self.message("work")) self.assertTrue(original.instance.entered.wait(1)) try: with self.assertRaises(RuntimeError): self.manager.load_one("busy") self.assertIs(self.manager.records["busy"], original) self.assertTrue(original.active) finally: original.instance.release.set() def test_old_session_reply_is_rejected_after_reconnect(self): old = self.message("old") self.service.close() replacement = MemoryBackend() self.service.bind(replacement) context = PluginContext("test", {}, self.service, CAPABILITIES) with self.assertRaises(RuntimeError): context.reply(old, "不应发送") self.assertEqual(replacement.sent, []) def test_runtime_waits_reconnects_and_stops_cleanly(self): created = [] def factory(): backend = MemoryBackend(fail_start=not created) created.append(backend) return backend app = BotApplication(self.root, self.store, backend_factory=factory) thread = threading.Thread(target=app.run) thread.start() try: wait_for(lambda: len(created) >= 2 and app._connection_ready) created[1].last_error = RuntimeError("模拟断线") wait_for(lambda: len(created) >= 3 and app._connection_ready) created[2].emit(Message.from_native(11046, {"msg": "test", "from_wxid": "filehelper"}, created[2].session_id)) wait_for(lambda: created[2].sent == [("filehelper", "filehelper")]) app.commands.put("quit") thread.join(3) self.assertFalse(thread.is_alive()) self.assertTrue(all(backend.stops == 1 for backend in created)) self.assertFalse(app.service.connected) finally: app.stop_event.set() thread.join(3) def test_single_instance_lock_is_released(self): path = self.root / "bot.lock" with InstanceLock(path): with self.assertRaises(RuntimeError): InstanceLock(path).acquire() with InstanceLock(path): pass def test_plugins_reply_to_friend_and_group_conversations(self): from wxbot_framework.text import TextRouter router = TextRouter() router.convert("wxid_self", "filehelper", "init") self.manager.reload() for sender, text in (("wxid_friend", "echo 好友"), ("12345@chatroom", "wxid_member:\necho 群聊"), ("12345@chatroom", "wxid_member:\ntest")): data = router.convert(sender, "wxid_self", text) self.manager.dispatch(Message.from_native(11046, data, self.backend.session_id)) wait_for(lambda: len(self.backend.sent) == 3) self.assertCountEqual(self.backend.sent, [("wxid_friend", "好友"), ("12345@chatroom", "群聊"), ("12345@chatroom", "12345@chatroom")]) def test_runtime_allowlist_dispatches_only_selected_chat(self): self.config["backend"] = "native_text" self.config["chat_allowlist"] = ["filehelper"] self.write_config() app = BotApplication(self.root, self.store, backend_factory=lambda: self.backend) app.service.bind(self.backend) app.plugins.reload() try: app._emit(Message(text="test", chat_id="wxid_other", session_id=self.backend.session_id, data={"msg": "test", "from_wxid": "wxid_other"})) app._emit(self.message("echo 允许")) wait_for(lambda: app.plugins.records["id_responder"].handled == 1 and app.plugins.records["echo"].handled == 1) self.assertEqual(self.backend.sent, [("filehelper", "允许")]) finally: app.plugins.shutdown() if __name__ == "__main__": unittest.main()