first commit

This commit is contained in:
Prometheus-first
2026-10-15 18:27:46 +08:00
commit 68eb19ea35
88 changed files with 10319 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
"""账号自动识别、登录切换和启动前准备的验证,不发送微信消息。"""
from __future__ import annotations
from pathlib import Path
import struct
import sys
import unittest
from unittest.mock import Mock, patch
from wxbot_framework.account import (APP_GLOBAL_RVA, APP_VTABLE_RVA, CONTEXT_VTABLE_RVA,
APP_CONTEXT_GETTER_RVA, IDENTITY_GETTER_RVA, read_current_account,
select_account_process, read_current_profile)
from wxbot_framework.text import TextRouter
BASE, APP, CONTEXT, HEAP = 0x100000000, 0x300000000, 0x400000000, 0x500000000
SELF = "wxid_framework_current"
class AccountMemory:
def __init__(self, account=SELF):
self.regions = {}
self.add(BASE + APP_GLOBAL_RVA, 8)
self.add(APP, 0x80)
self.add(BASE + APP_VTABLE_RVA, 0x80)
self.add(CONTEXT, 0x100)
self.add(BASE + CONTEXT_VTABLE_RVA, 0x20)
self.add(BASE + IDENTITY_GETTER_RVA, 5)
self.add(HEAP, 128)
self.write(BASE + APP_GLOBAL_RVA, struct.pack("<Q", APP))
self.write(APP, struct.pack("<Q", BASE + APP_VTABLE_RVA))
self.write(APP + 0x68, struct.pack("<Q", CONTEXT))
self.write(BASE + APP_VTABLE_RVA + 0x60, struct.pack("<Q", BASE + APP_CONTEXT_GETTER_RVA))
self.write(CONTEXT, struct.pack("<Q", BASE + CONTEXT_VTABLE_RVA))
self.write(BASE + CONTEXT_VTABLE_RVA + 0x18, struct.pack("<Q", BASE + IDENTITY_GETTER_RVA))
self.write(BASE + IDENTITY_GETTER_RVA, b"\x48\x8d\x41\x48\xc3")
self.set_account(account)
# 微信号和账号 ID 是不同字段,不能把旁边的别名当成自身 ID。
alias = b"alias_name"
self.write(CONTEXT + 0x68, alias.ljust(16, b"\0") + struct.pack("<QQ", len(alias), 15))
def add(self, address, size):
self.regions[address] = bytearray(size)
def region(self, address, size):
for start, data in self.regions.items():
if start <= address and address + size <= start + len(data):
return data, address - start
raise OSError("模拟账号对象已释放")
def write(self, address, value):
region, offset = self.region(address, len(value))
region[offset:offset + len(value)] = value
def read(self, address, size):
region, offset = self.region(address, size)
return bytes(region[offset:offset + size])
def set_account(self, account):
body = account.encode("ascii")
if len(body) <= 15:
header = body.ljust(16, b"\0") + struct.pack("<QQ", len(body), 15)
else:
self.write(HEAP, body)
header = struct.pack("<QQQQ", HEAP, 0, len(body), 127)
self.write(CONTEXT + 0x48, header)
class AccountTests(unittest.TestCase):
def test_current_profile_reads_only_id_and_wechat_name(self):
ram = AccountMemory()
name = '测试号🌸'.encode('utf-8')
ram.write(CONTEXT + 0x88, name.ljust(16, b'\0') + struct.pack('<QQ', len(name), 15))
self.assertEqual(read_current_profile(ram.read, BASE), {'wxid': SELF, 'name': '测试号🌸'})
ram.write(CONTEXT + 0x88, b'\0' * 16 + struct.pack('<QQ', 0, 15))
self.assertIsNone(read_current_profile(ram.read, BASE))
def test_current_profile_change_during_snapshot_is_rejected(self):
ram = AccountMemory()
ram.write(CONTEXT + 0x88, b'DemoUser'.ljust(16, b'\0') + struct.pack('<QQ', 5, 15))
changed = False
def changing(address, size):
nonlocal changed
value = ram.read(address, size)
if address == CONTEXT + 0x88 and size == 32 and not changed:
changed = True
ram.set_account('wxid_different')
return value
self.assertIsNone(read_current_profile(changing, BASE))
def test_bound_account_is_selected_regardless_of_pid_or_enumeration_order(self):
for candidates in ([(123, "wxid_personal"), (456, SELF)],
[(789, SELF), (123, "wxid_personal")]):
expected = next(pid for pid, account in candidates if account == SELF)
self.assertEqual(select_account_process(candidates, SELF), expected)
def test_bound_account_does_not_fall_back_to_personal_account(self):
with self.assertRaisesRegex(RuntimeError, "等待绑定"):
select_account_process([(123, "wxid_personal"), (456, None)], SELF)
with self.assertRaisesRegex(RuntimeError, "等待唯一"):
select_account_process([(123, SELF), (456, SELF)], SELF)
def test_unbound_selection_uses_unique_logged_account_and_rejects_ambiguity(self):
self.assertEqual(select_account_process([(123, None), (456, SELF)]), 456)
with self.assertRaisesRegex(RuntimeError, "多个微信账号"):
select_account_process([(123, SELF), (456, "wxid_personal")])
with self.assertRaisesRegex(RuntimeError, "等待微信"):
select_account_process([(123, None)])
def test_live_layout_reads_id_not_alias_for_inline_and_heap_strings(self):
for account in (SELF, "old_username"):
ram = AccountMemory(account)
self.assertEqual(read_current_account(ram.read, BASE), account)
def test_not_logged_in_missing_context_and_unexpected_layout_are_not_cached(self):
for address, value in ((CONTEXT + 0x48, b"\0" * 16 + struct.pack("<QQ", 0, 15)),
(APP + 0x68, b"\0" * 8),
(CONTEXT, struct.pack("<Q", BASE + 0x1234)),
(BASE + IDENTITY_GETTER_RVA, b"\0" * 5)):
ram = AccountMemory()
ram.write(address, value)
self.assertIsNone(read_current_account(ram.read, BASE))
def test_malformed_or_non_account_strings_are_rejected(self):
for account in ("filehelper", "12345@chatroom", "gh_public", "weixin"):
ram = AccountMemory(account)
self.assertIsNone(read_current_account(ram.read, BASE))
ram = AccountMemory()
ram.write(CONTEXT + 0x48 + 16, struct.pack("<QQ", 128, 127))
self.assertIsNone(read_current_account(ram.read, BASE))
def test_account_string_changes_during_snapshot_are_retried(self):
ram = AccountMemory()
reads = 0
def changing_read(address, size):
nonlocal reads
if address == CONTEXT + 0x48 and size == 32:
reads += 1
if reads == 2:
ram.set_account("wxid_new_account")
return ram.read(address, size)
self.assertIsNone(read_current_account(changing_read, BASE))
self.assertEqual(read_current_account(ram.read, BASE), "wxid_new_account")
def test_login_context_changes_during_snapshot_are_retried(self):
ram = AccountMemory()
reads = 0
def changing_read(address, size):
nonlocal reads
if address == APP + 0x68:
reads += 1
if reads == 2:
ram.write(APP + 0x68, b"\0" * 8)
return ram.read(address, size)
self.assertIsNone(read_current_account(changing_read, BASE))
def test_first_private_message_works_without_filehelper_initialization(self):
router = TextRouter()
router.set_account(read_current_account(AccountMemory().read, BASE))
data = router.convert("wxid_friend", SELF, "echo 开机直接用")
self.assertEqual(data["chat_id"], "wxid_friend")
self.assertEqual(data["msg"], "echo 开机直接用")
self.assertEqual(router.unidentified, 0)
@unittest.skipUnless(sys.platform == "win32", "本机原生服务检查")
def test_service_prepares_identity_before_receive_thread(self):
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "native"))
from text_service import NativeTextService
service = NativeTextService()
service._read_account = Mock(return_value=SELF)
service._prepare_channel()
self.assertEqual(service.self_wxid, SELF)
self.assertIsNone(service._thread)
service._read_account.return_value = None
with self.assertRaisesRegex(RuntimeError, "自动等待"):
service._prepare_channel()
@unittest.skipUnless(sys.platform == "win32", "本机原生服务检查")
def test_explicit_wrong_pid_is_rejected_before_loading_bridge(self):
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "native"))
from text_service import NativeTextService
service = NativeTextService(pid=123, account=SELF)
service._run = Mock()
with patch("text_service.inspect_account", return_value="wxid_personal"):
with self.assertRaisesRegex(RuntimeError, "不是绑定"):
service.start()
service._run.assert_not_called()
self.assertFalse(service._owns_bridge)
@unittest.skipUnless(sys.platform == "win32", "本机原生服务检查")
def test_explicit_pid_cannot_override_bound_account(self):
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "native"))
from text_service import NativeTextService
service = NativeTextService(pid=123, account=SELF)
service._read_account = Mock(return_value="wxid_personal")
with self.assertRaisesRegex(RuntimeError, "不是绑定"):
service._prepare_channel()
self.assertEqual(service.self_wxid, "")
@unittest.skipUnless(sys.platform == "win32", "本机原生服务检查")
def test_send_rechecks_account_and_rejects_old_login_before_native_call(self):
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "native"))
from text_service import NativeTextService
service = NativeTextService()
service.router.set_account(SELF)
service._owns_bridge = True
service._read_account = Mock(return_value="wxid_different_account")
service._run = Mock()
with self.assertRaisesRegex(RuntimeError, "当前账号发生变化"):
service.send_text("wxid_friend", "不能通过新账号发送旧回复")
service._run.assert_not_called()
self.assertTrue(service._stop.is_set())
self.assertIsNotNone(service.last_error)
if __name__ == "__main__":
unittest.main()
+416
View File
@@ -0,0 +1,416 @@
"""今日记录、午夜清理、完整分批总结与清空竞态;不连接微信或外部 API。"""
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
from plugins.chat_records import Plugin as ClearPlugin
from plugins.chat_summary import Plugin as SummaryPlugin
from wxbot_framework.config import ConfigStore
from wxbot_framework.llm import LLMError
from wxbot_framework.members import MemberStore, SnapshotExpired
from wxbot_framework.runtime import BotApplication
from wxbot_framework.sdk import Message, PluginContext, ServiceFacade
from wxbot_framework.summary import ChatSummarizer, conversation_rows, split_text
from wxbot_framework.text import TextRouter
ROOT = Path(__file__).resolve().parent.parent
ROOM, OTHER_ROOM = "123@chatroom", "456@chatroom"
ROBOT, CAT, OTHER = "wxid_robot", "amy123", "wxid_other"
class Backend:
session_id = "test"
def __init__(self):
self.sent = []
def send_text(self, room, text):
self.sent.append((room, text))
return True
def stop(self):
return True
class DailySummaryTests(unittest.TestCase):
def setUp(self):
scratch = ROOT / ".runtime/tests"
scratch.mkdir(parents=True, exist_ok=True)
self.temporary = tempfile.TemporaryDirectory(dir=scratch)
self.root = Path(self.temporary.name)
self.now = datetime(2026, 10, 10, 15, 59, 59, tzinfo=timezone.utc) # 北京 23:59:59
options = {"database_file": "data.sqlite3", "history_enabled": True, "daily_clear": True,
"groups": {ROOM: [], OTHER_ROOM: []}}
self.path = self.root / "members.json"
self.path.write_text(json.dumps(options), encoding="utf-8")
self.stores = []
self.store = self.new_store()
self.store.register("测试成员", ROOM, CAT)
self.store.register("别人", ROOM, OTHER)
self.store.register("测试成员", OTHER_ROOM, CAT)
self.backend = Backend()
self.service = ServiceFacade(text_delay={"enabled": False})
self.service.bind(self.backend)
self.ctx = PluginContext("summary", {}, self.service, [], member_store=self.store)
self.calls = []
self.ctx._llm = Mock(complete_json=self.model)
self.plugin = SummaryPlugin()
self.plugin.on_load(self.ctx)
self.clear_plugin = ClearPlugin()
self.clear_plugin.on_load(self.ctx)
def tearDown(self):
self.service.close()
for store in self.stores:
store.close()
self.assertTrue(self.root.resolve().is_relative_to((ROOT / ".runtime/tests").resolve()))
self.temporary.cleanup()
def new_store(self, account=ROBOT):
store = MemberStore(self.path, account, now=lambda: self.now)
self.stores.append(store)
store.initialize()
return store
def message(self, text, sender=CAT, room=ROOM, sequence=0, **kwargs):
return Message(text, room, sender_id=sender, is_group=True, session_id="test", sequence=sequence, **kwargs)
def model(self, system, user):
self.calls.append(json.loads(user))
return {"summary": "讨论了晚餐和明天的安排。"}
def snapshot(self):
return self.store.today_snapshot("测试成员", ROOM)
def test_midnight_clears_idle_buffer_preserves_identity_and_invalidates_snapshot(self):
self.store.record(self.message("今晚聊聊天"))
snapshot = self.snapshot()
self.assertEqual(snapshot.day, "2026-10-10")
self.now += timedelta(seconds=1)
self.assertTrue(self.store.rollover())
self.assertEqual(self.store.history("测试成员", ROOM), [])
self.assertFalse(self.store.snapshot_is_current(snapshot))
self.assertEqual(self.store.resolve("测试成员", ROOM).wxid, CAT)
self.store.record(self.message("新的一天"))
self.assertEqual([r["text"] for r in self.snapshot().messages], ["新的一天"])
self.assertEqual(self.snapshot().day, "2026-10-11")
def test_restart_after_missed_midnight_cleans_previous_day(self):
self.store.record(self.message("旧记录"))
self.store.close()
self.now += timedelta(days=2)
reopened = self.new_store()
self.assertEqual(reopened.today_snapshot("测试成员", ROOM).messages, ())
self.assertEqual(reopened.history("测试成员", ROOM), [])
def test_restart_same_day_retains_all_text_and_aliases(self):
self.store.record(self.message("今天的话"))
self.store.register("测试成员新名", ROOM, CAT)
reopened = self.new_store()
self.assertEqual(reopened.resolve("测试成员", ROOM).name, "测试成员新名")
self.assertEqual(len(reopened.today_snapshot("测试成员新名", ROOM).messages), 1)
def test_clear_isolates_groups_and_accounts_without_deleting_bindings(self):
self.store.record(self.message("这个群"))
self.store.record(self.message("另一个群", room=OTHER_ROOM))
other = self.new_store("wxid_second_robot")
other.register("测试成员", ROOM, CAT)
other.record(self.message("另一个账号"))
snapshot = self.snapshot()
self.assertEqual(self.store.clear(ROOM), 1)
self.assertFalse(self.store.snapshot_is_current(snapshot))
self.assertEqual(len(self.store.today_snapshot("测试成员", OTHER_ROOM).messages), 1)
self.assertEqual(len(other.today_snapshot("测试成员", ROOM).messages), 1)
self.assertEqual(self.store.resolve("测试成员", ROOM).wxid, CAT)
def test_today_snapshot_does_not_truncate_at_console_history_limit(self):
for index in range(1005):
self.store.record(self.message(str(index), sequence=index + 1))
self.assertEqual(len(self.snapshot().messages), 1005)
self.assertEqual(self.snapshot().messages[0]["text"], "0")
self.assertEqual(len(self.store.today_group_snapshot(ROOM).messages), 1005)
def test_bot_output_stored_but_excluded_from_member_summary(self):
self.store.record(self.message("机器人输出", data={"bot_generated": True}))
self.store.record(self.message("自己手机的话"))
self.assertEqual(len(self.store.history("测试成员", ROOM)), 2)
self.assertEqual([r["text"] for r in self.snapshot().messages], ["自己手机的话"])
def test_summary_uses_current_room_and_actual_id_and_filters_commands(self):
for text in ("晚饭吃面", "/绑定 测试成员", "/总结 测试成员", "/总结群聊", "/help"):
self.store.record(self.message(text))
self.store.record(self.message("别人的话", sender=OTHER))
self.store.record(self.message("另一个群的话", room=OTHER_ROOM))
self.plugin.on_message(self.message("/总结 测试成员", sender=OTHER), self.ctx)
self.assertEqual(len(self.calls), 1)
data = self.calls[0]
self.assertEqual([r["text"] for r in data["data"]], ["晚饭吃面"])
self.assertNotIn(CAT, json.dumps(data))
self.assertIn("1条", self.backend.sent[-1][1])
def test_unknown_member_and_no_chat_do_not_call_model(self):
self.plugin.on_message(self.message("/总结 未绑定"), self.ctx)
self.assertIn("请本人先发送 /绑定", self.backend.sent[-1][1])
self.plugin.on_message(self.message("/总结 测试成员"), self.ctx)
self.assertIn("没有可总结", self.backend.sent[-1][1])
self.assertEqual(self.calls, [])
def test_summary_and_clear_validate_commands_and_ignore_private_chats(self):
self.plugin.on_message(self.message("/总结"), self.ctx)
self.assertEqual(self.backend.sent[-1][1], "用法:/总结 昵称")
self.clear_plugin.on_message(self.message("/清空 别人"), self.ctx)
self.assertEqual(self.backend.sent[-1][1], "用法:/清空")
count = len(self.backend.sent)
self.plugin.on_message(Message("/总结 测试成员", CAT), self.ctx)
self.clear_plugin.on_message(Message("/清空", CAT), self.ctx)
self.assertEqual(len(self.backend.sent), count)
def test_manual_clear_command_keeps_member_registration(self):
self.store.record(self.message("可清空的记录"))
self.clear_plugin.on_message(self.message("/清空"), self.ctx)
self.assertEqual(self.snapshot().messages, ())
self.assertEqual(self.backend.sent[-1][1], "已清空本群聊天记录。")
self.assertEqual(self.store.resolve("测试成员", ROOM).wxid, CAT)
def test_all_long_records_are_covered_and_partials_are_merged(self):
texts = [f"第{i}条" + "内容" * 950 for i in range(9)]
for text in texts:
self.store.record(self.message(text))
summary = ChatSummarizer(self.ctx.llm, chunk_chars=4000)
snapshot = self.snapshot()
summary.summarize(snapshot, conversation_rows(snapshot), current=lambda: True)
received = [item for call in self.calls for item in call["data"] if "text" in item]
for index, original in enumerate(texts, 1):
self.assertEqual("".join(item["text"] for item in received if item["record"] == index), original)
self.assertTrue(any(call["task"] == "合并为最终总结" for call in self.calls))
self.assertTrue(all(len(json.dumps(call, ensure_ascii=False)) <= 4000 for call in self.calls))
def test_clear_during_llm_call_cancels_result(self):
self.store.record(self.message("不能清空后再发"))
def clear_during_request(system, user):
self.clear_plugin.on_message(self.message("/清空"), self.ctx)
return {"summary": "旧总结"}
self.ctx._llm = Mock(complete_json=clear_during_request)
self.plugin.on_load(self.ctx)
self.plugin.on_message(self.message("/总结 测试成员"), self.ctx)
self.assertEqual(self.backend.sent, [(ROOM, "已清空本群聊天记录。")])
def test_json_escaping_does_not_overflow_small_batches(self):
text = "\x01" * 2000
self.store.record(self.message(text))
snapshot = self.snapshot()
ChatSummarizer(self.ctx.llm, chunk_chars=4000).summarize(
snapshot, conversation_rows(snapshot), current=lambda: True)
self.assertTrue(all(len(json.dumps(call, ensure_ascii=False)) <= 4000 for call in self.calls))
units = [unit for call in self.calls for unit in call["data"] if "text" in unit]
self.assertEqual("".join(unit["text"] for unit in units), text)
def test_midnight_during_request_cancels_result(self):
self.store.record(self.message("旧天聊天"))
def midnight(system, user):
self.now += timedelta(seconds=1)
return {"summary": "昨天的总结"}
self.ctx._llm = Mock(complete_json=midnight)
self.plugin.on_load(self.ctx)
self.plugin.on_message(self.message("/总结 测试成员"), self.ctx)
self.assertEqual(self.backend.sent, [])
def test_clear_during_pacing_prevents_native_send(self):
self.store.record(self.message("旧记录"))
snapshot = self.snapshot()
cancel = Mock(is_set=lambda: False)
def wait(seconds):
self.store.clear(ROOM)
return False
cancel.wait = wait
self.service._cancel = cancel
with self.assertRaises(SnapshotExpired):
self.ctx.reply(self.message("/总结 测试成员"), "待发送的旧总结",
delivery_guard=lambda: self.store.delivery_guard(snapshot))
self.assertEqual(self.backend.sent, [])
def test_new_messages_do_not_cancel_existing_snapshot(self):
snapshot = self.snapshot()
self.store.record(self.message("新消息"))
self.assertTrue(self.store.snapshot_is_current(snapshot))
def test_llm_error_returns_short_message_and_preserves_records(self):
self.store.record(self.message("原始聊天"))
self.ctx._llm = Mock(complete_json=Mock(side_effect=LLMError("模拟超时")))
self.plugin.on_load(self.ctx)
self.plugin.on_message(self.message("/总结 测试成员"), self.ctx)
self.assertIn("暂时无法生成", self.backend.sent[-1][1])
self.assertEqual(len(self.snapshot().messages), 1)
def test_malformed_summary_is_rejected(self):
self.store.record(self.message("聊天"))
snapshot = self.snapshot()
for result in ({}, {"summary": ""}, {"summary": 1}, {"summary": "a\0b"}, {"summary": "a\x01b"}, {"summary": "字" * 601}, []):
client = Mock(complete_json=Mock(return_value=result))
with self.subTest(result_type=type(result)), self.assertRaises(LLMError):
ChatSummarizer(client).summarize(snapshot, conversation_rows(snapshot), current=lambda: True)
def test_utf8_chunks_keep_all_characters_and_fit_native_limit(self):
text = "中文🌸abc\n" * 300
parts = split_text(text)
self.assertEqual("".join(parts), text)
self.assertTrue(all(0 < len(part.encode("utf-8")) <= 1023 for part in parts))
def test_owner_group_messages_are_archived_without_reply_loops(self):
router = TextRouter(include_self_groups=True)
router.set_account(ROBOT)
data = router.convert(ROOM, ROBOT, ROBOT + ":\n普通手机聊天")
self.assertFalse(data["dispatch_to_plugins"])
self.store.register("主人", ROOM, ROBOT)
# 验证 runtime 确实先记下文本,再跳过插件。
app = object.__new__(BotApplication)
app.config = {"chat_allowlist": []}
app.members, app.plugins = self.store, Mock()
app._emit(Message.from_native(11046, data, "test"))
app.plugins.dispatch.assert_not_called()
self.assertEqual(self.store.today_snapshot("主人", ROOM).messages[0]["text"], "普通手机聊天")
for command in ("/绑定 主人", "/总结 测试成员", "/总结群聊", "/清空"):
self.assertTrue(router.convert(ROOM, ROBOT, ROBOT + ":\n" + command)["dispatch_to_plugins"])
def test_group_snapshot_includes_unbound_and_owner_but_isolates_room_and_account(self):
self.store.record(self.message("测试成员先说"))
self.store.record(self.message("未绑定者接话", sender="wxid_unbound"))
self.store.record(self.message("主人手机说话", sender=ROBOT, is_self=True))
self.store.record(self.message("测试成员再说"))
self.store.record(self.message("机器人输出", sender=ROBOT, is_self=True, data={"bot_generated": True}))
self.store.record(self.message("另一个群", room=OTHER_ROOM))
other = self.new_store("wxid_second_robot")
other.record(self.message("另一个账号的记录"))
snapshot = self.store.today_group_snapshot(ROOM)
self.assertEqual([row["text"] for row in snapshot.messages],
["测试成员先说", "未绑定者接话", "主人手机说话", "测试成员再说"])
self.assertEqual([row["speaker"] for row in snapshot.messages], ["P1", "P2", "P3", "P1"])
self.assertEqual(snapshot.messages[0]["speaker_name"], "测试成员")
self.assertEqual(snapshot.messages[1]["speaker_name"], "未绑定成员2")
self.assertEqual(snapshot.room, ROOM)
def test_group_command_sends_all_people_without_raw_ids_and_filters_commands(self):
self.store.record(self.message("测试成员想吃面"))
self.store.record(self.message("别人想吃饭", sender=OTHER))
self.store.record(self.message("未绑定者订餐", sender="wxid_unbound"))
self.store.record(self.message("/总结群聊", sender=ROBOT, is_self=True))
self.store.record(self.message("/总结 测试成员"))
client = Mock(complete_json=Mock(side_effect=self.model))
self.ctx._llm = client
self.plugin.on_load(self.ctx)
self.plugin.on_message(self.message("/总结群聊", sender=ROBOT, is_self=True), self.ctx)
self.assertEqual(self.calls[0]["scope"], "group")
self.assertEqual([unit["text"] for unit in self.calls[0]["data"]],
["测试成员想吃面", "别人想吃饭", "未绑定者订餐"])
self.assertEqual([unit["speaker_name"] for unit in self.calls[0]["data"]],
["测试成员", "别人", "未绑定成员3"])
payload = json.dumps(self.calls, ensure_ascii=False)
for wxid in (ROBOT, CAT, OTHER, "wxid_unbound", ROOM):
self.assertNotIn(wxid, payload)
self.assertIn("当前微信群所有人的聊天", client.complete_json.call_args.args[0])
self.assertIn("今日群聊总结", self.backend.sent[-1][1])
self.assertIn("3条,3人", self.backend.sent[-1][1])
def test_group_command_empty_or_only_commands_does_not_call_model(self):
for text in ("/绑定 测试成员", "/help", "/总结群聊"):
self.store.record(self.message(text))
self.plugin.on_message(self.message("/总结群聊"), self.ctx)
self.assertEqual(self.calls, [])
self.assertEqual(self.backend.sent[-1][1], "本群今天还没有可总结的聊天记录。")
def test_group_command_requires_exact_command_no_argument_and_group_context(self):
self.plugin.on_message(self.message("/总结群聊 测试成员"), self.ctx)
self.assertEqual(self.backend.sent[-1][1], "用法:/总结群聊")
count = len(self.backend.sent)
self.plugin.on_message(self.message("/总结群聊测试"), self.ctx)
self.plugin.on_message(Message("/总结群聊", CAT), self.ctx)
self.plugin.on_message(self.message("/总结群聊", room="999@chatroom"), self.ctx)
self.assertEqual(len(self.backend.sent), count)
self.assertEqual(self.calls, [])
def test_group_clear_during_request_cancels_result(self):
self.store.record(self.message("今天一起吃饭"))
def clear_during_request(system, user):
self.clear_plugin.on_message(self.message("/清空"), self.ctx)
return {"summary": "已被清空的全群聊天"}
self.ctx._llm = Mock(complete_json=clear_during_request)
self.plugin.on_load(self.ctx)
self.plugin.on_message(self.message("/总结群聊"), self.ctx)
self.assertEqual(self.backend.sent, [(ROOM, "已清空本群聊天记录。")])
def test_group_midnight_during_request_cancels_result(self):
self.store.record(self.message("旧日全群聊天"))
def midnight(system, user):
self.now += timedelta(seconds=1)
return {"summary": "昨天的全群总结"}
self.ctx._llm = Mock(complete_json=midnight)
self.plugin.on_load(self.ctx)
self.plugin.on_message(self.message("/总结群聊"), self.ctx)
self.assertEqual(self.backend.sent, [])
self.assertEqual(self.store.today_group_snapshot(ROOM).messages, ())
def test_group_guard_after_send_delay_detects_clear(self):
self.store.record(self.message("全群快照"))
snapshot = self.store.today_group_snapshot(ROOM)
cancel = Mock(is_set=lambda: False)
def wait(seconds):
self.store.clear(ROOM)
return False
cancel.wait = wait
self.service._cancel = cancel
with self.assertRaises(SnapshotExpired):
self.ctx.reply(self.message("/总结群聊"), "待发送全群总结",
delivery_guard=lambda: self.store.delivery_guard(snapshot))
self.assertEqual(self.backend.sent, [])
def test_long_group_batches_preserve_speakers_all_text_and_json_size(self):
self.store.register("\x01" * 64, ROOM, CAT)
originals = ["\x02" * 2000, "其他人" * 1000, "最后" * 2000]
for index, text in enumerate(originals):
self.store.record(self.message(text, sender=CAT if index != 1 else OTHER))
snapshot = self.store.today_group_snapshot(ROOM)
ChatSummarizer(self.ctx.llm, chunk_chars=4000).summarize(
snapshot, conversation_rows(snapshot), current=lambda: True)
self.assertTrue(all(len(json.dumps(call, ensure_ascii=False)) <= 4000 for call in self.calls))
units = [unit for call in self.calls for unit in call["data"] if "text" in unit]
for index, text in enumerate(originals, 1):
parts = [unit for unit in units if unit["record"] == index]
self.assertEqual("".join(unit["text"] for unit in parts), text)
self.assertTrue(all(unit["speaker"] == ("P2" if index == 2 else "P1") for unit in parts))
self.assertTrue(any(call["task"] == "合并为最终总结" for call in self.calls))
def test_group_llm_error_preserves_all_people_records(self):
self.store.record(self.message("测试成员今天的话"))
self.store.record(self.message("别人今天的话", sender=OTHER))
self.ctx._llm = Mock(complete_json=Mock(side_effect=LLMError("模拟群聊总结失败")))
self.plugin.on_load(self.ctx)
self.plugin.on_message(self.message("/总结群聊"), self.ctx)
self.assertIn("暂时无法生成", self.backend.sent[-1][1])
self.assertEqual(len(self.store.today_group_snapshot(ROOM).messages), 2)
def test_runtime_rollover_runs_without_new_messages(self):
# 禁用连接和所有插件,只验证主循环的定时清理调用。
config = json.loads((ROOT / "bot_config.json").read_text(encoding="utf-8"))
config["members_config_file"] = "members.json"
config["wechat_account"] = ROBOT
config["console"] = False
for settings in config["plugins"].values():
settings["enabled"] = False
config_path = self.root / "bot_config.json"
config_path.write_text(json.dumps(config), encoding="utf-8")
(self.root / "plugins").mkdir()
app = BotApplication(self.root, ConfigStore(config_path))
app.members.close()
app.members = Mock()
app._connect = Mock()
app.run(seconds=0.65)
self.assertGreaterEqual(app.members.rollover.call_count, 2)
if __name__ == "__main__":
unittest.main()
+98
View File
@@ -0,0 +1,98 @@
"""默认微信名读取和缓存变化验证,不连接微信。"""
import struct
import unittest
from test_account import AccountMemory, BASE, CONTEXT, SELF
from wxbot_framework.account import CONTEXT_VTABLE_RVA, IDENTITY_GETTER_RVA
from wxbot_framework.contacts import (CACHE_PROVIDER_GETTER_RVA, CACHE_PROVIDER_VTABLE_RVA,
CONTACT_CACHE_VTABLE_RVA, read_contact_name)
PEER = 'wxid_contact_name'
PROVIDER, HEAD, NODE, CACHE, SENTINEL, BUCKETS, ENTRY, CONTACT, STRINGS = [
0x600000000 + i * 0x1000 for i in range(9)]
class ContactMemory(AccountMemory):
def __init__(self, name='DemoUser'):
super().__init__()
self.add(BASE + CONTEXT_VTABLE_RVA, 0x80)
self.write(BASE + CONTEXT_VTABLE_RVA + 0x18, struct.pack('<Q', BASE + IDENTITY_GETTER_RVA))
self.add(BASE + CACHE_PROVIDER_GETTER_RVA + 28, 8)
self.write(BASE + CACHE_PROVIDER_GETTER_RVA + 28, bytes.fromhex('48 83 b9 60 04 00 00 00'))
self.add(CONTEXT + 0x460, 8)
self.write(CONTEXT + 0x460, struct.pack('<Q', PROVIDER))
self.write(BASE + CONTEXT_VTABLE_RVA + 0x38, struct.pack('<Q', BASE + CACHE_PROVIDER_GETTER_RVA))
for address, length in ((PROVIDER, 0xC0), (HEAD, 0x40), (NODE, 0x40), (CACHE, 0x200),
(SENTINEL, 0x40), (BUCKETS, 32), (ENTRY, 0x40), (CONTACT, 0x100), (STRINGS, 1024)):
self.add(address, length)
self.q(PROVIDER, BASE + CACHE_PROVIDER_VTABLE_RVA)
self.q(PROVIDER + 0x90, HEAD)
self.q(PROVIDER + 0x98, 1)
self.q(HEAD, NODE)
self.q(NODE, HEAD)
self.q(NODE + 0x30, CACHE)
self.q(CACHE, BASE + CONTACT_CACHE_VTABLE_RVA)
self.q(CACHE + 0xD0, SENTINEL)
self.q(CACHE + 0xD8, 1)
self.q(CACHE + 0xE0, BUCKETS)
self.q(CACHE + 0xF8, 1)
hashed = 0xCBF29CE484222325
for byte in PEER.encode():
hashed = ((hashed ^ byte) * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
for slot in (0, 16):
self.write(BUCKETS + slot, struct.pack('<QQ', SENTINEL, SENTINEL))
self.write(BUCKETS + (hashed & 1) * 16, struct.pack('<QQ', ENTRY, ENTRY))
self.q(ENTRY + 0x30, CONTACT)
self.s(ENTRY + 0x10, PEER, STRINGS)
self.s(CONTACT + 8, PEER, STRINGS + 128)
self.s(CONTACT + 0xD8, name, STRINGS + 256)
def q(self, address, value):
self.write(address, struct.pack('<Q', value))
def s(self, address, value, buffer):
body = value.encode('utf-8')
if len(body) <= 15:
self.write(address, body.ljust(16, b'\0') + struct.pack('<QQ', len(body), 15))
else:
self.write(buffer, body)
self.write(address, struct.pack('<QQQQ', buffer, 0, len(body), 511))
class ContactTests(unittest.TestCase):
def test_verified_id_returns_wechat_name_for_inline_and_unicode_heap_strings(self):
for name in ('DemoUser', '微信原始名字🌸很长'):
ram = ContactMemory(name)
self.assertEqual(read_contact_name(ram.read, BASE, PEER, expected_account=SELF), name)
def test_other_account_and_unknown_member_never_get_someone_elses_name(self):
ram = ContactMemory()
self.assertIsNone(read_contact_name(ram.read, BASE, PEER, expected_account='wxid_other'))
self.assertIsNone(read_contact_name(ram.read, BASE, 'wxid_missing'))
ram.s(CONTACT + 8, 'wxid_other', STRINGS + 128)
self.assertIsNone(read_contact_name(ram.read, BASE, PEER))
def test_wrong_layout_and_unavailable_cache_are_skipped(self):
for address, value in ((PROVIDER, BASE + 1234), (CACHE, BASE + 5678),
(CACHE + 0xF8, 123), (CONTEXT + 0x460, 0)):
ram = ContactMemory()
ram.q(address, value)
self.assertIsNone(read_contact_name(ram.read, BASE, PEER))
def test_cache_changes_during_read_do_not_return_a_torn_name(self):
ram = ContactMemory()
changed = False
def changing(address, size):
nonlocal changed
result = ram.read(address, size)
if address == CONTACT + 0xD8 and not changed:
changed = True
ram.q(CACHE + 0xD8, 2)
return result
self.assertIsNone(read_contact_name(changing, BASE, PEER))
self.assertEqual(read_contact_name(ram.read, BASE, PEER), 'DemoUser')
def test_empty_and_control_character_names_are_rejected(self):
for name in ('', '错误\n昵称', 'x' * 65):
ram = ContactMemory(name)
self.assertIsNone(read_contact_name(ram.read, BASE, PEER))
+290
View File
@@ -0,0 +1,290 @@
"""不连接微信的框架行为测试;发送结果由内存后端记录。"""
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()
+182
View File
@@ -0,0 +1,182 @@
"""验证独立接龙、合并首答和发送失败时的状态,使用内存上下文。"""
import logging
import time
import unittest
from contextlib import nullcontext
from unittest.mock import patch
from plugins.idiom_chain import Plugin, HELP
from wxbot_framework.llm import LLMError
from wxbot_framework.sdk import Message
from wxbot_framework.personality import PersonalityState
ROOM = "12345678@chatroom"
A, B = "wxid_player_a", "wxid_player_b"
class JSONClient:
def __init__(self, result):
self.result = result
def complete_json(self, system, user):
return self.result
class Context:
def __init__(self):
self.config = {"member_names": {ROOM: {A: "小明"}}}
self.llm = JSONClient({"idiom": "一心一意"})
self.logger = logging.getLogger("IdiomTests")
self.sent = []
self.succeeds = True
def personality(self, message):
return PersonalityState(message.chat_id)
def llm_for(self, message, *, personality=None):
return self.llm
def personality_guard(self, state):
return nullcontext()
def reply(self, message, text, *, delivery_guard=None):
self.sent.append((message.chat_id, text, None, None))
return self.succeeds
def reply_at(self, message, text, *, display_name=None, delivery_guard=None):
self.sent.append((message.chat_id, text, message.sender_id, display_name))
return self.succeeds
class IdiomPluginTests(unittest.TestCase):
def setUp(self):
self.ctx = Context()
self.plugin = Plugin()
self.plugin.on_load(self.ctx)
def send(self, text, sender=A, room=ROOM, *, session="one", received_at=None, is_self=False, is_group=True):
message = Message(text, room, sender_id=sender, session_id=session, is_group=is_group,
is_self=is_self, received_at=time.monotonic() if received_at is None else received_at)
self.plugin.on_message(message, self.ctx)
return message
def start(self):
self.send("成语接龙")
self.ctx.llm.result = {"is_idiom": True, "reason": "常见成语", "next_idiom": "发扬光大"}
def test_two_players_are_independent_and_use_member_names(self):
self.start()
# 两个答案都在机器人第一次回复之前到达:B 仍应从开场词接。
b_received = time.monotonic()
self.send("意气风发", A)
self.send("意气风发", B, received_at=b_received)
game = self.plugin.games[ROOM]
self.assertEqual(game.players[A].current, "发扬光大")
self.assertEqual(game.players[B].current, "发扬光大")
self.assertEqual(self.ctx.sent[-2][2:], (A, "小明"))
self.assertEqual(self.ctx.sent[-1][2:], (B, None))
self.ctx.llm.result["next_idiom"] = "方兴未艾"
self.send("大大方方", A)
self.assertEqual(game.players[A].current, "方兴未艾")
self.assertEqual(game.players[B].current, "发扬光大")
def test_groups_do_not_share_game(self):
self.start()
self.send("意气风发", room="87654321@chatroom")
self.assertEqual(len(self.ctx.sent), 1)
def test_bad_answer_keeps_current_question(self):
self.start()
self.send("异想天开")
self.assertEqual(self.plugin.games[ROOM].players[A].current, "一心一意")
self.assertIn("接龙失败", self.ctx.sent[-1][1])
def test_merged_mode_uses_latest_word_and_only_first_received_answer(self):
self.start()
self.send("意气风发")
self.send("合并接龙")
game = self.plugin.games[ROOM]
self.assertEqual(game.shared.current, "发扬光大")
self.assertEqual(game.players, {})
arrived = time.monotonic()
self.ctx.llm.result["next_idiom"] = "方兴未艾"
self.send("大大方方", A, received_at=arrived)
count = len(self.ctx.sent)
self.send("大大方方", B, received_at=arrived + 0.00000001)
self.assertEqual(len(self.ctx.sent), count)
self.assertIsNone(self.ctx.sent[-1][2])
self.assertEqual(game.shared.current, "方兴未艾")
def test_failed_merged_answer_still_consumes_round(self):
self.start()
self.send("合并接龙")
arrived = time.monotonic()
self.send("异想天开", received_at=arrived)
count = len(self.ctx.sent)
self.send("意气风发", B, received_at=arrived)
self.assertEqual(len(self.ctx.sent), count)
self.assertEqual(self.plugin.games[ROOM].shared.current, "一心一意")
self.send("意气风发", B)
self.assertEqual(self.plugin.games[ROOM].shared.current, "发扬光大")
def test_exit_and_restart(self):
self.start()
self.send("退出接龙")
self.assertNotIn(ROOM, self.plugin.games)
count = len(self.ctx.sent)
self.send("意气风发")
self.assertEqual(len(self.ctx.sent), count)
self.ctx.llm.result = {"idiom": "一心一意"}
self.send("成语接龙")
self.assertIn(ROOM, self.plugin.games)
def test_help_and_primary_phone_commands(self):
self.send("/help", is_self=True)
self.assertEqual(self.ctx.sent[-1][1], HELP)
self.assertEqual(HELP, "1. 成语接龙\n\n2. /绑定 昵称\n\n3. /总结 昵称\n\n4. /总结群聊\n\n5. /切换人格 嘴臭|卑微|夸夸|取消\n\n6. 艾特机器人对话\n\n7. /清空\n\n8. /help")
self.assertLessEqual(len(HELP.encode("utf-8")), 1023)
self.send("成语接龙", is_self=True)
self.assertEqual(self.ctx.sent[-1][1], "一心一意")
self.assertIn(ROOM, self.plugin.games)
self.ctx.llm.result = {"is_idiom": True, "reason": "常见成语", "next_idiom": "发扬光大"}
self.send("意气风发", is_self=True)
self.assertEqual(self.plugin.games[ROOM].players[A].current, "发扬光大")
def test_own_long_reply_does_not_create_loop_and_private_is_ignored(self):
self.start()
count = len(self.ctx.sent)
self.send("接龙成功!一心一意 → 意气风发", is_self=True)
self.send("/help", is_group=False)
self.assertEqual(len(self.ctx.sent), count)
def test_reconnect_resets_game(self):
self.start()
self.send("意气风发", session="two")
self.assertEqual(self.plugin.games, {})
def test_failed_send_does_not_advance_question(self):
self.start()
self.ctx.succeeds = False
self.send("意气风发")
self.assertEqual(self.plugin.games[ROOM].players, {})
self.send("合并接龙")
self.assertEqual(self.plugin.games[ROOM].mode, "independent")
def test_model_failure_keeps_question(self):
self.start()
with patch("plugins.idiom_chain.IdiomJudge.evaluate", side_effect=LLMError("测试超时")), self.assertLogs("IdiomTests"):
self.send("意气风发")
self.assertEqual(self.plugin.games[ROOM].players[A].current, "一心一意")
self.assertIn("重试", self.ctx.sent[-1][1])
def test_model_opening_failure_does_not_create_game(self):
with patch("plugins.idiom_chain.IdiomJudge.opening", side_effect=LLMError("测试超时")), self.assertLogs("IdiomTests"):
self.send("成语接龙")
self.assertEqual(self.plugin.games, {})
def test_invalid_member_name_configuration(self):
for options in ({"member_names": []}, {"member_names": {ROOM: {A: "\n"}}},
{"member_names": {ROOM: {A: "A" * 65}}}):
self.ctx.config = options
with self.subTest(options=options), self.assertRaises(ValueError):
Plugin().on_load(self.ctx)
+120
View File
@@ -0,0 +1,120 @@
"""大模型协议和错误处理验证;使用假响应,不消耗实际 API。"""
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from urllib.error import HTTPError, URLError
from wxbot_framework.llm import LLMClient, LLMError
from plugins.idiom_chain.judge import IdiomJudge
class JSONClient:
def __init__(self, result):
self.result = result
self.calls = []
def complete_json(self, system, user):
self.calls.append((system, user))
return self.result
class LLMTests(unittest.TestCase):
def setUp(self):
self.options = {"api_key": "dummy-test-key", "model": "deepseek-flash"}
self.client = LLMClient(self.options)
def response(self, content, **extra):
return io.BytesIO(json.dumps({"choices": [{"message": {"content": content}, **extra}]}).encode())
def test_official_json_request_and_utf8(self):
with patch("wxbot_framework.llm.urlopen", return_value=self.response('{"idiom":"一心一意"}')) as call:
self.assertEqual(self.client.complete_json("只返回 JSON", "中文"), {"idiom": "一心一意"})
request = call.call_args.args[0]
self.assertEqual(request.full_url, "https://api.deepseek.com/chat/completions")
self.assertEqual(request.get_header("Authorization"), "Bearer dummy-test-key")
payload = json.loads(request.data)
self.assertEqual(payload["model"], "deepseek-flash")
self.assertEqual(payload["messages"][1]["content"], "中文")
self.assertEqual(payload["response_format"], {"type": "json_object"})
self.assertEqual(payload["thinking"], {"type": "disabled"})
self.assertEqual(call.call_args.kwargs["timeout"], 20)
def test_config_from_utf8_bom_file(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "llm.json"
path.write_text(json.dumps(self.options), encoding="utf-8-sig")
self.assertEqual(LLMClient.from_file(path).model, "deepseek-flash")
def test_invalid_configuration(self):
for change in ({"api_key": ""}, {"model": 1}, {"base_url": None},
{"base_url": "http://example.com"}, {"base_url": "https://user:pass@example.com"},
{"timeout_seconds": True}, {"timeout_seconds": float("nan")},
{"max_tokens": 1}, {"max_tokens": True}):
with self.subTest(change=change), self.assertRaises(ValueError):
LLMClient({**self.options, **change})
def test_server_and_connection_errors_hide_sensitive_details(self):
for error in (HTTPError("https://example.com", 401, "dummy-test-key", {}, None),
URLError("dummy-test-key"), TimeoutError("dummy-test-key")):
with self.subTest(error=type(error).__name__), patch("wxbot_framework.llm.urlopen", side_effect=error):
with self.assertRaises(LLMError) as caught:
self.client.complete_json("JSON", "测试")
self.assertNotIn("dummy-test-key", str(caught.exception))
def test_malformed_or_truncated_model_results(self):
for content in ("", "not json", "[]", None):
with self.subTest(content=content), patch("wxbot_framework.llm.urlopen", return_value=self.response(content)):
with self.assertRaises(LLMError):
self.client.complete_json("JSON", "测试")
with patch("wxbot_framework.llm.urlopen", return_value=self.response("{}", finish_reason="length")):
with self.assertRaisesRegex(LLMError, "截断"):
self.client.complete_json("JSON", "测试")
with patch("wxbot_framework.llm.urlopen", return_value=io.BytesIO(b'{"choices":[null]}')):
with self.assertRaises(LLMError):
self.client.complete_json("JSON", "测试")
class JudgeTests(unittest.TestCase):
def setUp(self):
self.client = JSONClient({"is_idiom": True, "reason": "常见成语", "next_idiom": "发扬光大"})
self.judge = IdiomJudge(self.client)
def test_local_rejections_do_not_call_model(self):
for previous, word, used in (("一心一意", "不是四字成语", ()),
("一心一意", "异想天开", ()),
("一心一意", "意气风发", ("意气风发",))):
self.assertFalse(self.judge.evaluate(previous, word, used).accepted)
self.assertEqual(self.client.calls, [])
def test_success_sends_only_game_words(self):
result = self.judge.evaluate("一心一意", "意气风发", ("一心一意",))
self.assertTrue(result.accepted)
self.assertEqual(result.next_idiom, "发扬光大")
self.assertEqual(json.loads(self.client.calls[0][1]),
{"previous": "一心一意", "candidate": "意气风发", "used": ["一心一意", "意气风发"]})
def test_model_can_reject_fake_four_character_word(self):
self.client.result = {"is_idiom": False, "reason": "不是成语", "next_idiom": ""}
result = self.judge.evaluate("一心一意", "意意意意", ("一心一意",))
self.assertFalse(result.accepted)
def test_invalid_model_next_word_never_advances(self):
for word in ("假的", "一心一意", "发扬光大", "意气风发"):
self.client.result["next_idiom"] = word
with self.subTest(word=word), self.assertRaises(LLMError):
self.judge.evaluate("一心一意", "意气风发", ("一心一意", "发扬光大"))
def test_model_boolean_is_strict(self):
self.client.result["is_idiom"] = "true"
with self.assertRaises(LLMError):
self.judge.evaluate("一心一意", "意气风发", ())
def test_opening_shape(self):
self.client.result = {"idiom": "一心一意"}
self.assertEqual(self.judge.opening(), "一心一意")
self.client.result = {"idiom": "假词"}
with self.assertRaises(LLMError):
self.judge.opening()
+320
View File
@@ -0,0 +1,320 @@
"""成员绑定、持久记录和插件共用接口验证,不连接微信。"""
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)
+68
View File
@@ -0,0 +1,68 @@
"""真正艾特必须同时携带原生成员 ID 与可见标签,并保留会话限制。"""
import unittest
from unittest.mock import patch
from wxbot_framework.sdk import CapabilityError, Message, PluginContext, ServiceFacade
from wxbot_framework.text import validate_mentions
ROOM = "12345678@chatroom"
MEMBER = "wxid_test_member"
class Backend:
session_id = "one"
def send_at_text(self, room, text, members):
self.sent = (room, text, members)
return True
def stop(self):
return {"dll_unloaded": True}
class MentionTests(unittest.TestCase):
def setUp(self):
self.backend = Backend()
self.service = ServiceFacade(text_delay={"enabled": False})
self.service.bind(self.backend)
def tearDown(self):
self.service.close()
def test_nickname_and_actual_member_are_separate(self):
self.service.send_at_text(ROOM, "测试", [MEMBER], display_names=["群昵称"])
self.assertEqual(self.backend.sent, (ROOM, "@群昵称\u2005\n测试", (MEMBER,)))
def test_reply_at_keeps_session_and_uses_sender(self):
ctx = PluginContext("example", {}, self.service, [])
message = Message("测试", ROOM, MEMBER, is_group=True, session_id="one")
ctx.reply_at(message, "成功", display_name="小明")
self.assertEqual(self.backend.sent[2], (MEMBER,))
with self.assertRaises(RuntimeError):
ctx.reply_at(Message("旧消息", ROOM, MEMBER, is_group=True, session_id="old"), "跳过")
with self.assertRaises(ValueError):
ctx.reply_at(Message("私聊", MEMBER, MEMBER), "跳过")
def test_missing_native_ability_is_not_plain_text_at(self):
self.backend.send_at_text = None
with self.assertRaises(CapabilityError):
self.service.send_at_text(ROOM, "测试", [MEMBER])
def test_invalid_members_and_labels(self):
for room, members in ((MEMBER, [MEMBER]), (ROOM, []), (ROOM, [MEMBER, MEMBER]),
(ROOM, ["filehelper"]), (ROOM, ["其他@chatroom"]), (ROOM, ["中文"]),
(ROOM, ["wxid_" + str(index) for index in range(9)])):
with self.subTest(members=members), self.assertRaises(ValueError):
validate_mentions(room, members)
for names in ([], [""], ["换\n行"], ["x" * 65]):
with self.subTest(names=names), self.assertRaises(ValueError):
self.service.send_at_text(ROOM, "测试", [MEMBER], display_names=names)
def test_size_limit_includes_visible_names(self):
with self.assertRaises(ValueError):
self.service.send_at_text(ROOM, "文" * 341, [MEMBER], display_names=["小明"])
def test_pacing_applies_to_mention_text(self):
with patch.object(type(self.service.text_delay), "seconds_for", return_value=0) as delay:
self.service.send_at_text(ROOM, "成功", [MEMBER], display_names=["小明"])
self.assertEqual(delay.call_args.args[0], "@小明\u2005\n成功")
+359
View File
@@ -0,0 +1,359 @@
"""群人格持久性、统一风格、真实艾特与十条上下文及取消竞态。"""
import json
import sys
import unittest
from datetime import timedelta
from pathlib import Path
from unittest.mock import Mock
import test_chat_summary as daily
from plugins.mention_chat import Plugin as ChatPlugin
from plugins.personality import Plugin as PersonalityPlugin
from plugins.idiom_chain import Plugin as IdiomPlugin
from wxbot_framework.dialogue import Dialogue
from wxbot_framework.members import MemberError, SnapshotExpired
from wxbot_framework.mentions import parse_at_members, strip_at_labels
from wxbot_framework.personality import PROMPTS, PersonalityExpired, PersonalityState, prepare_switch, game_reply
from wxbot_framework.runtime import BotApplication
from wxbot_framework.sdk import Message
from wxbot_framework.text import TextRouter
ROOM, CAT, ROBOT, OTHER_ROOM, OTHER = daily.ROOM, daily.CAT, daily.ROBOT, daily.OTHER_ROOM, daily.OTHER
class PersonalityTests(unittest.TestCase):
setUp = daily.DailySummaryTests.setUp
tearDown = daily.DailySummaryTests.tearDown
new_store = daily.DailySummaryTests.new_store
message = daily.DailySummaryTests.message
model = daily.DailySummaryTests.model
snapshot = daily.DailySummaryTests.snapshot
def chat(self, text="你怎么看?", *, sequence=100, sender=CAT, at_members=(ROBOT,), **kwargs):
msg = self.message("@机器人\u2005" + text, sequence=sequence, sender=sender,
data={"at_members": at_members, "is_at_self": True, **kwargs})
self.store.record(msg)
return msg
def setup_chat(self, provider=None):
self.backend.send_at_text = lambda room, text, ids: self.backend.sent.append((room, text, ids)) or True
self.ctx._llm = Mock(complete_json=provider or Mock(return_value={"reply": "这是回答。", "summary": "这是总结。"}))
plugin = ChatPlugin()
plugin.on_load(self.ctx)
return plugin
def test_personality_persists_and_isolated_from_other_groups_accounts_and_clear(self):
state = self.store.set_personality(ROOM, "嘴臭")
self.assertEqual(self.store.personality(OTHER_ROOM).name, "默认")
self.assertEqual(self.new_store("wxid_second_robot").personality(ROOM).name, "默认")
self.store.clear(ROOM)
self.now += timedelta(seconds=1)
self.store.rollover()
self.assertEqual(self.store.personality(ROOM), state)
self.assertEqual(self.new_store().personality(ROOM), state)
def test_same_selection_keeps_generation_unknown_name_does_not_change_it(self):
state = self.store.set_personality(ROOM, "卑微")
self.assertEqual(self.store.set_personality(ROOM, "卑微"), state)
with self.assertRaises(MemberError):
self.store.set_personality(ROOM, "不存在")
self.assertEqual(self.store.personality(ROOM), state)
self.assertGreater(self.store.set_personality(ROOM, "夸夸").generation, state.generation)
def test_cancel_removes_style_persists_and_keeps_records_and_other_groups(self):
initial = self.store.personality(ROOM)
repeated = prepare_switch(self.message("/切换人格 取消"), self.store)
self.assertEqual(repeated.data["personality_switch"], initial)
self.store.record(self.message("今天写了文档", sequence=1))
old = self.store.set_personality(ROOM, "嘴臭")
other = self.store.set_personality(OTHER_ROOM, "夸夸")
prepared = prepare_switch(self.message("/切换人格 取消"), self.store)
state = prepared.data["personality_switch"]
self.assertEqual(state.name, "默认")
self.assertGreater(state.generation, old.generation)
plugin = PersonalityPlugin()
plugin.on_load(self.ctx)
plugin.on_message(prepared, self.ctx)
self.assertIn("已取消人格", self.backend.sent[-1][1])
self.assertEqual(len(self.store.today_snapshot("测试成员", ROOM).messages), 1)
self.assertEqual(self.store.personality(OTHER_ROOM), other)
self.assertEqual(self.new_store().personality(ROOM), state)
self.assertEqual(prepare_switch(self.message("/切换人格 取消"), self.store).data["personality_switch"], state)
provider = Mock(return_value={"reply": "正常回答"})
self.ctx._llm = Mock(complete_json=provider)
self.ctx.llm_for(self.message("问题")).complete_json("原功能提示", "输入")
self.assertEqual(provider.call_args.args[0], "原功能提示")
def test_cancel_during_model_request_discards_old_personality_reply(self):
state = self.store.set_personality(ROOM, "嘴臭")
def cancel(system, user):
prepare_switch(self.message("/切换人格 取消"), self.store)
return {"reply": "旧人格回答"}
self.ctx._llm = Mock(complete_json=cancel)
client = self.ctx.llm_for(self.message("问题"), personality=state)
with self.assertRaises(PersonalityExpired):
client.complete_json("功能", "输入")
with self.assertRaises(PersonalityExpired):
with self.store.personality_delivery_guard(state):
self.fail("取消后不能发送旧人格回答")
self.assertEqual(self.store.personality(ROOM).name, "默认")
def test_runtime_switches_before_dispatch_and_ack_uses_same_state(self):
app = object.__new__(BotApplication)
app.config, app.members, app.plugins = {"chat_allowlist": []}, self.store, Mock()
app._emit(self.message("/切换人格 嘴臭"))
delivered = app.plugins.dispatch.call_args.args[0]
self.assertEqual(self.store.personality(ROOM).name, "嘴臭")
self.assertEqual(delivered.data["personality_switch"].name, "嘴臭")
plugin = PersonalityPlugin()
plugin.on_load(self.ctx)
plugin.on_message(delivered, self.ctx)
self.assertIn("已切换:嘴臭", self.backend.sent[-1][1])
def test_missing_or_unknown_switch_returns_usage_without_changing_persona(self):
plugin = PersonalityPlugin()
plugin.on_load(self.ctx)
for command in ("/切换人格", "/切换人格 未知", "/切换人格 嘴臭 卑微"):
prepared = prepare_switch(self.message(command), self.store)
plugin.on_message(prepared, self.ctx)
self.assertIn("用法", self.backend.sent[-1][1])
self.assertEqual(self.store.personality(ROOM).name, "默认")
def test_disabled_personality_plugin_does_not_apply_switch(self):
app = object.__new__(BotApplication)
app.config, app.members, app.plugins = {"chat_allowlist": []}, self.store, Mock()
app.plugins.is_active.return_value = False
app._emit(self.message("/切换人格 嘴臭"))
self.assertEqual(self.store.personality(ROOM).name, "默认")
prepare_switch(self.message("/切换人格 嘴臭", data={"bot_generated": True}), self.store)
self.assertEqual(self.store.personality(ROOM).name, "默认")
def test_owner_phone_can_switch_and_default_style_does_not_modify_system(self):
router = TextRouter(include_self_groups=True)
router.set_account(ROBOT)
data = router.convert(ROOM, ROBOT, ROBOT + ":\n/切换人格 夸夸")
self.assertTrue(data["dispatch_to_plugins"])
provider = Mock(return_value={"reply": "回答"})
self.ctx._llm = Mock(complete_json=provider)
self.ctx.llm_for(self.message("问题")).complete_json("原功能提示", "输入")
self.assertEqual(provider.call_args.args[0], "原功能提示")
def test_personality_prompt_applies_to_member_and_group_summaries(self):
self.store.record(self.message("今天要认真工作"))
for name in PROMPTS:
self.store.set_personality(ROOM, name)
provider = Mock(return_value={"summary": "总结内容"})
self.ctx._llm = Mock(complete_json=provider)
for command in ("/总结 测试成员", "/总结群聊"):
self.plugin.on_message(self.message(command), self.ctx)
system = provider.call_args.args[0]
self.assertTrue(system.startswith(f"当前人格:{name}"))
self.assertIn("最高优先级", system)
self.assertIn("JSON", system)
def test_personality_change_during_model_request_discards_summary(self):
self.store.record(self.message("当天记录"))
self.store.set_personality(ROOM, "嘴臭")
def change(system, user):
self.store.set_personality(ROOM, "卑微")
return {"summary": "旧人格结果"}
self.ctx._llm = Mock(complete_json=change)
self.plugin.on_message(self.message("/总结群聊"), self.ctx)
self.assertEqual(self.backend.sent, [])
def test_old_llm_session_does_not_call_provider_after_switch(self):
state = self.store.set_personality(ROOM, "嘴臭")
provider = Mock(return_value={"reply": "旧结果"})
self.ctx._llm = Mock(complete_json=provider)
client = self.ctx.llm_for(self.message("问题"), personality=state)
self.store.set_personality(ROOM, "卑微")
with self.assertRaises(PersonalityExpired):
client.complete_json("功能", "输入")
provider.assert_not_called()
def test_context_is_previous_ten_from_same_author_and_room_not_later_messages(self):
for index in range(12):
self.store.record(self.message(f"前文{index}", sequence=index + 1))
self.store.record(self.message("其他人的话", sender=OTHER))
self.store.record(self.message("其他群的话", room=OTHER_ROOM))
self.store.record(self.message("/切换人格 嘴臭"))
self.store.record(self.message("机器人输出", data={"bot_generated": True}))
msg = self.chat("当前提问")
self.store.record(self.message("当前提问之后才收到的消息", sequence=101))
snapshot = self.store.conversation_snapshot(msg)
self.assertEqual([row["text"] for row in snapshot.messages], [f"前文{i}" for i in range(2, 12)])
self.assertEqual(snapshot.current, msg.text)
def test_true_mention_dialogue_uses_persona_context_and_returns_real_at(self):
self.store.set_personality(ROOM, "嘴臭")
for index in range(3):
self.store.record(self.message(f"我今天第{index}次摸鱼", sequence=index + 1))
provider = Mock(return_value={"reply": "你这效率,进度条看了都想请假。"})
plugin = self.setup_chat(provider)
msg = self.chat("我今天又摸鱼了")
plugin.on_message(msg, self.ctx)
system, user = provider.call_args.args
self.assertTrue(system.startswith("当前人格:嘴臭"))
payload = json.loads(user)
self.assertEqual(len(payload["previous"]), 3)
self.assertEqual(payload["current"], "我今天又摸鱼了")
self.assertNotIn(CAT, user)
self.assertEqual(self.backend.sent[-1][2], (CAT,))
self.assertTrue(self.backend.sent[-1][1].startswith("@测试成员\u2005"))
def test_short_mouth_dialogue_is_plain_and_keeps_real_at_and_context(self):
self.store.set_personality(ROOM, "嘴臭")
self.store.record(self.message("今天写了三行代码", sequence=1))
provider = Mock(return_value={"reply": "```text\n**三行代码就敢吹,先把手上的任务搞定再说。**\n```"})
plugin = self.setup_chat(provider)
plugin.on_message(self.chat("我已经很强了"), self.ctx)
sent = self.backend.sent[-1]
body = strip_at_labels(sent[1])
self.assertEqual(sent[2], (CAT,))
self.assertLessEqual(len(body), 20)
self.assertTrue(body.startswith("三行代码"))
self.assertFalse(any(char in body for char in "`#*-|"))
self.assertEqual(len(json.loads(provider.call_args.args[1])["previous"]), 1)
self.assertIn("长度与排版优先", provider.call_args.args[0])
provider.return_value = {"reply": "#**```"}
plugin.on_message(self.chat(sequence=101), self.ctx)
self.assertIn("暂时无法回复", self.backend.sent[-1][1])
def test_short_mouth_summaries_omit_titles_and_cancel_restores_them(self):
self.store.record(self.message("今天写了三行代码,剩下的明天做"))
self.store.set_personality(ROOM, "嘴臭")
self.ctx._llm = Mock(complete_json=Mock(return_value={"summary": "**代码写三行,吹牛倒是写满了一整本。剩下的明天再说。**"}))
for command in ("/总结 测试成员", "/总结群聊"):
self.plugin.on_message(self.message(command), self.ctx)
body = self.backend.sent[-1][1]
self.assertLessEqual(len(body), 20)
self.assertTrue(body.startswith("代码写三行"))
self.assertNotIn("今日", body)
self.assertNotIn("*", body)
prepare_switch(self.message("/切换人格 取消"), self.store)
self.plugin.on_message(self.message("/总结 测试成员"), self.ctx)
self.assertTrue(self.backend.sent[-1][1].startswith("测试成员 今日聊天"))
def test_short_mouth_idiom_reply_keeps_the_next_word_without_formatting(self):
success = game_reply("嘴臭", True, "", "一心一意", "意气风发", "发愤图强")
failure = game_reply("嘴臭", False, "**末字和首字不相同,请认真核对接字**", "一心一意", "乱写", "一心一意")
self.assertIn("发愤图强", success)
self.assertIn("一心一意", failure)
for text in (success, failure):
self.assertLessEqual(len(text), 20)
self.assertFalse(any(char in text for char in "`#*-|"))
def test_mention_of_other_person_and_bot_output_do_not_call_model(self):
provider = Mock(return_value={"reply": "回答"})
plugin = self.setup_chat(provider)
for msg in (self.chat(at_members=(OTHER,)), self.chat(sequence=101, bot_generated=True),
self.message("@机器人 只是可见文字"), Message("私聊", CAT)):
plugin.on_message(msg, self.ctx)
provider.assert_not_called()
self.assertEqual(self.backend.sent, [])
def test_unbound_person_can_chat_and_bot_does_not_reply_to_id_text(self):
plugin = self.setup_chat()
plugin.on_message(self.chat(sender="wxid_unbound"), self.ctx)
self.assertTrue(self.backend.sent[-1][1].startswith("@群友\u2005"))
self.assertEqual(self.backend.sent[-1][2], ("wxid_unbound",))
def test_cleared_current_message_or_day_change_does_not_reuse_history(self):
plugin = self.setup_chat()
msg = self.chat()
self.store.clear(ROOM)
plugin.on_message(msg, self.ctx)
self.assertEqual(self.backend.sent, [])
msg = self.chat(sequence=102)
self.now += timedelta(seconds=1)
plugin.on_message(msg, self.ctx)
self.assertEqual(self.backend.sent, [])
def test_clear_and_personality_change_during_chat_request_cancel_reply(self):
for mutation in (lambda: self.store.clear(ROOM), lambda: self.store.set_personality(ROOM, "卑微")):
self.store.set_personality(ROOM, "嘴臭")
def change(system, user):
mutation()
return {"reply": "失效的结果"}
plugin = self.setup_chat(change)
plugin.on_message(self.chat(), self.ctx)
self.assertEqual(self.backend.sent, [])
def test_personality_change_during_at_send_pacing_cancels_native_send(self):
self.setup_chat()
msg = self.chat()
snapshot = self.store.conversation_snapshot(msg)
state = self.store.set_personality(ROOM, "嘴臭")
cancel = Mock(is_set=lambda: False)
def wait(seconds):
self.store.set_personality(ROOM, "卑微")
return False
cancel.wait = wait
self.service._cancel = cancel
with self.assertRaises(PersonalityExpired):
self.ctx.reply_at(msg, "旧回复", display_name="测试成员",
delivery_guard=lambda: self.ctx.response_guard(snapshot, state))
self.assertEqual(self.backend.sent, [])
def test_long_reply_respects_byte_limit_including_mention_nickname(self):
self.store.register("🌸" * 64, ROOM, CAT)
plugin = self.setup_chat(Mock(return_value={"reply": "中文🌸" * 190}))
plugin.on_message(self.chat(), self.ctx)
self.assertGreater(len(self.backend.sent), 1)
self.assertTrue(all(len(row[1].encode("utf-8")) <= 1023 for row in self.backend.sent))
self.assertTrue(all(row[2] == (CAT,) for row in self.backend.sent))
def test_invalid_model_reply_is_reported_without_deleting_context(self):
self.store.record(self.message("历史", sequence=1))
plugin = self.setup_chat(Mock(return_value={"reply": ""}))
plugin.on_message(self.chat(), self.ctx)
self.assertIn("暂时无法回复", self.backend.sent[-1][1])
self.assertEqual(len(self.store.conversation_snapshot(self.chat(sequence=101)).messages), 2)
def test_idiom_game_skips_at_dialogue_and_style_keeps_correct_next_word(self):
plugin = IdiomPlugin()
plugin.on_load(self.ctx)
plugin.games[ROOM] = Mock()
plugin.on_message(self.chat(), self.ctx)
self.assertEqual(self.backend.sent, [])
for name in PROMPTS:
reply = game_reply(name, True, "正确", "一心一意", "意气风发", "发扬光大")
self.assertIn("发扬光大", reply)
self.assertIn("以“大”", reply)
class NativeAtReceiveTests(unittest.TestCase):
def test_cdata_and_multiple_members_are_parsed_without_guessing_names(self):
self.assertEqual(parse_at_members("<msgsource><atuserlist><![CDATA[wxid_a,amy123,wxid_a]]></atuserlist></msgsource>"),
("wxid_a", "amy123"))
self.assertEqual(parse_at_members("<msgsource><atuserlist>filehelper,坏名字,wxid_b</atuserlist></msgsource>"), ("wxid_b",))
for source in ("", "@机器人", "<msgsource>", "<!DOCTYPE a [<!ENTITY x 'abc'>]><a>&x;</a>", "x" * 4096):
self.assertEqual(parse_at_members(source), ())
def test_visible_labels_are_removed_on_unicode_at_boundary(self):
self.assertEqual(strip_at_labels("@机器 人\u2005@小明\u2005请看看这句话"), "请看看这句话")
self.assertEqual(strip_at_labels("普通 @example.com 文本"), "普通 @example.com 文本")
def test_native_metadata_marks_only_actual_robot_group_at(self):
native = daily.ROOT / "native"
if str(native) not in sys.path:
sys.path.insert(0, str(native))
from text_service import NativeTextService
from filehelper_service import ReceivedTextMessage
service = NativeTextService(pid=123)
service.router.set_account(ROBOT)
msg = ReceivedTextMessage(type=1, sender=ROOM.encode(), receiver=ROBOT.encode(),
text=(CAT + ":\n@机器人\u2005你好").encode(),
mention_source=f"<msgsource><atuserlist>{ROBOT}</atuserlist></msgsource>".encode())
self.assertTrue(service._map_message(msg)["is_at_self"])
msg.mention_source = f"<msgsource><atuserlist>{OTHER}</atuserlist></msgsource>".encode()
self.assertFalse(service._map_message(msg)["is_at_self"])
msg.mention_source = b""
self.assertFalse(service._map_message(msg)["is_at_self"])
msg.sender, msg.text = CAT.encode(), "普通私聊".encode()
msg.mention_source = f"<msgsource><atuserlist>{ROBOT}</atuserlist></msgsource>".encode()
self.assertFalse(service._map_message(msg)["is_at_self"])
if __name__ == "__main__":
unittest.main()
+182
View File
@@ -0,0 +1,182 @@
"""文本节奏、统一发送入口及等待期间的退出/重连验证;不连接微信。"""
from __future__ import annotations
import threading
import time
import unittest
from unittest.mock import Mock, patch
from wxbot_framework.pacing import TextSendDelay
from wxbot_framework.sdk import CapabilityError, LegacyService, Message, PluginContext, ServiceFacade
class MemoryBackend:
def __init__(self, session="original"):
self.session_id = session
self.sent = []
self.stopped = False
def send_text(self, chat, text):
self.sent.append((chat, text, time.monotonic()))
return True
def stop(self):
self.stopped = True
return {"dll_unloaded": True}
class RecordingCancel:
def __init__(self):
self.waits = []
self.cancelled = False
def wait(self, seconds):
self.waits.append(seconds)
return self.cancelled
def is_set(self):
return self.cancelled
def set(self):
self.cancelled = True
class TextPacingTests(unittest.TestCase):
def test_character_groups_include_chinese_symbols_and_short_text(self):
pacing = TextSendDelay.from_config()
with patch("wxbot_framework.pacing.random.uniform", return_value=0.2):
self.assertAlmostEqual(pacing.seconds_for("你好🌸 abc!"), 0.2)
self.assertAlmostEqual(pacing.seconds_for("字" * 10), 0.2)
self.assertAlmostEqual(pacing.seconds_for("字" * 11), 0.4)
self.assertAlmostEqual(pacing.seconds_for("字" * 100), 2.0)
self.assertEqual(TextSendDelay.from_config({"enabled": False}).seconds_for("你好"), 0)
def test_configuration_rejects_invalid_random_ranges_and_group_size(self):
for options in ([], {"enabled": 1}, {"characters_per_group": 0},
{"characters_per_group": True}, {"min_seconds": -1},
{"max_seconds": float("inf")}, {"max_seconds": float("nan")},
{"min_seconds": True}, {"min_seconds": 0.4, "max_seconds": 0.3}):
with self.subTest(options=options), self.assertRaises(ValueError):
TextSendDelay.from_config(options)
def test_all_plugin_text_apis_wait_and_other_payloads_do_not(self):
backend = MemoryBackend()
service = ServiceFacade()
service.bind(backend)
cancel = RecordingCancel()
service._cancel = cancel
ctx = PluginContext("test", {}, service, {"text.send"})
legacy = LegacyService(service, backend.session_id)
message = Message(text="test", chat_id="wxid_friend", session_id=backend.session_id)
with patch("wxbot_framework.pacing.random.uniform", return_value=0.2):
ctx.reply(message, "你好")
ctx.send_text("12345@chatroom", "群聊")
legacy.send_text("filehelper", "旧插件")
legacy.send_payload({"type": 11237, "data": {"to_wxid": "filehelper", "content": "文本"}})
with self.assertRaises(CapabilityError):
legacy.send_payload({"type": 99999})
self.assertEqual(cancel.waits, [0.2] * 4)
self.assertEqual(len(backend.sent), 4)
service.close()
def test_invalid_text_is_rejected_before_wait_or_native_send(self):
backend = MemoryBackend()
service = ServiceFacade()
service.bind(backend)
cancel = RecordingCancel()
service._cancel = cancel
for text in (None, "", "含\0字符", "字" * 342):
with self.subTest(text_type=type(text)), self.assertRaises(ValueError):
service.send_text("filehelper", text)
self.assertEqual(cancel.waits, [])
self.assertEqual(backend.sent, [])
service.close()
def test_two_concurrent_plugins_each_wait_between_native_sends(self):
backend = MemoryBackend()
service = ServiceFacade(text_delay={"min_seconds": 0.08, "max_seconds": 0.08})
service.bind(backend)
threads = [threading.Thread(target=service.send_text, args=("filehelper", str(i))) for i in range(2)]
started = time.monotonic()
for thread in threads:
thread.start()
for thread in threads:
thread.join(2)
self.assertFalse(thread.is_alive())
self.assertEqual(len(backend.sent), 2)
self.assertGreaterEqual(backend.sent[0][2] - started, 0.07)
self.assertGreaterEqual(backend.sent[1][2] - backend.sent[0][2], 0.07)
service.close()
def test_close_interrupts_wait_without_blocking_connection_queries(self):
backend = MemoryBackend()
service = ServiceFacade()
service.bind(backend)
waiting, errors = threading.Event(), []
def delay(_text):
waiting.set()
return 30
service.text_delay = Mock(seconds_for=delay)
thread = self._start_send(service, errors)
self.assertTrue(waiting.wait(1))
self.assertTrue(service.connected)
service.close()
thread.join(1)
self.assertFalse(thread.is_alive())
self.assertEqual(len(errors), 1)
self.assertEqual(backend.sent, [])
self.assertTrue(backend.stopped)
def test_pending_send_cannot_move_to_new_connection_even_without_session_argument(self):
original, replacement = MemoryBackend(), MemoryBackend("replacement")
service = ServiceFacade()
service.bind(original)
waiting, errors = threading.Event(), []
def delay(_text):
waiting.set()
return 30
service.text_delay = Mock(seconds_for=delay)
thread = self._start_send(service, errors)
self.assertTrue(waiting.wait(1))
service.close()
service.bind(replacement)
thread.join(1)
self.assertFalse(thread.is_alive())
self.assertEqual(len(errors), 1)
self.assertEqual(original.sent, [])
self.assertEqual(replacement.sent, [])
service.text_delay = TextSendDelay.from_config({"enabled": False})
service.send_text("filehelper", "新连接")
self.assertEqual(replacement.sent[0][:2], ("filehelper", "新连接"))
service.close()
def test_shutdown_cancellation_rejects_new_sends_before_backend_cleanup(self):
backend = MemoryBackend()
service = ServiceFacade(text_delay={"enabled": False})
service.bind(backend)
service.cancel_pending()
with self.assertRaises(RuntimeError):
service.send_text("filehelper", "退出期间不再发消息")
self.assertEqual(backend.sent, [])
self.assertFalse(backend.stopped)
service.close()
self.assertTrue(backend.stopped)
@staticmethod
def _start_send(service, errors):
def send():
try:
service.send_text("filehelper", "等待发送")
except RuntimeError as error:
errors.append(error)
thread = threading.Thread(target=send, daemon=True)
thread.start()
return thread
if __name__ == "__main__":
unittest.main()
+127
View File
@@ -0,0 +1,127 @@
"""好友/群消息路由和实际桥接 ABI 的回归验证,不向微信发送消息。"""
from __future__ import annotations
import ctypes
from pathlib import Path
import sys
import time
import unittest
from wxbot_framework.sdk import Message
from wxbot_framework.text import TextRouter, validate_target
ROOT = Path(__file__).resolve().parent.parent
NATIVE = ROOT / "native"
SELF = "wxid_framework_self"
FRIEND = "wxid_framework_friend"
GROUP = "12345678901234567890@chatroom"
class TextRoutingTests(unittest.TestCase):
def setUp(self):
self.router = TextRouter()
def identify(self):
return self.router.convert(SELF, "filehelper", "init")
def test_private_chat_requires_account_and_keeps_actual_sender(self):
self.assertIsNone(self.router.convert(FRIEND, SELF, "未识别"))
self.assertEqual(self.router.unidentified, 1)
self.identify()
data = self.router.convert(FRIEND, SELF, "echo 好友 🌸\n第二行", sequence=3)
message = Message.from_native(11046, data, "session")
self.assertEqual((message.chat_id, message.sender_id, message.receiver_id), (FRIEND, FRIEND, SELF))
self.assertFalse(message.is_group)
self.assertFalse(message.is_self)
self.assertEqual(message.text, "echo 好友 🌸\n第二行")
def test_group_identifies_account_and_separates_author_body_and_chat(self):
data = self.router.convert(GROUP, SELF, FRIEND + ":\necho 群聊 🌸\n二:\n三", sequence=9)
message = Message.from_native(11046, data, "session")
self.assertEqual(self.router.self_wxid, SELF)
self.assertEqual((message.chat_id, message.sender_id), (GROUP, FRIEND))
self.assertEqual(message.text, "echo 群聊 🌸\n二:\n三")
self.assertTrue(message.is_group)
self.assertEqual(data["from_wxid"], FRIEND)
self.assertEqual(data["room_wxid"], GROUP)
def test_phone_sent_private_and_group_messages_do_not_create_reply_loops(self):
self.identify()
self.assertIsNone(self.router.convert(SELF, FRIEND, "echo 私聊同步"))
self.assertIsNone(self.router.convert(SELF, GROUP, "echo 群同步"))
self.assertIsNone(self.router.convert(GROUP, SELF, SELF + ":\necho 自己的群消息"))
self.assertEqual(self.router.ignored_self, 3)
# 文件传输助手仍允许手机自己发送测试指令。
self.assertTrue(self.identify()["is_self"])
def test_unrecognized_group_author_is_not_guessed(self):
self.assertIsNone(self.router.convert(GROUP, SELF, "没有成员前缀"))
self.assertIsNone(self.router.convert(GROUP, SELF, "普通内容:\n正文"))
self.assertEqual(self.router.unparsed_groups, 2)
def test_primary_phone_can_control_and_play_group_game(self):
self.identify()
for text in ("/help", "成语接龙", "合并接龙", "退出接龙", "意气风发"):
data = self.router.convert(GROUP, SELF, SELF + ":\n" + text)
self.assertTrue(data["is_self"])
self.assertEqual(data["msg"], text)
def test_native_service_skips_recent_bot_text_but_allows_players(self):
if str(NATIVE) not in sys.path:
sys.path.insert(0, str(NATIVE))
from text_service import NativeTextService
from filehelper_service import ReceivedMessage, ReceivedTextMessage
service = NativeTextService(pid=123)
service.router.set_account(SELF)
service._sent_texts.append((GROUP, "意气风发", time.monotonic()))
own = ReceivedMessage(type=1, sender=GROUP.encode(), receiver=SELF.encode(),
text=(SELF + ":\n意气风发").encode("utf-8"))
mapped = service._map_message(own)
self.assertTrue(mapped["bot_generated"])
self.assertFalse(mapped["dispatch_to_plugins"])
own.text = (SELF + ":\n发扬光大").encode("utf-8")
self.assertEqual(service._map_message(own)["msg"], "发扬光大")
own.text = (FRIEND + ":\n意气风发").encode("utf-8")
self.assertEqual(service._map_message(own)["sender_id"], FRIEND)
def test_account_change_stops_old_connection(self):
self.identify()
with self.assertRaises(RuntimeError):
self.router.convert("wxid_another_account", "filehelper", "init")
self.assertEqual(self.router.self_wxid, SELF)
def test_public_and_system_peers_are_not_dispatched(self):
self.identify()
self.assertIsNone(self.router.convert("gh_example", SELF, "echo 公众号"))
self.assertIsNone(self.router.convert("weixin", SELF, "echo 系统"))
def test_target_validation_matches_native_buffer(self):
for target in ("filehelper", FRIEND, GROUP, "a" * 127):
self.assertEqual(validate_target(target), target)
for target in ("", "a" * 128, "好友名称", "wxid_a\0b", "a b", None):
with self.assertRaises(ValueError):
validate_target(target)
@unittest.skipUnless(sys.platform == "win32", "本机 Windows ABI 检查")
def test_compiled_bridge_exports_and_wire_structures(self):
if str(NATIVE) not in sys.path:
sys.path.insert(0, str(NATIVE))
import pefile
from run_selftest import Config, TextConfig, AtTextConfig, TEXT_DLL
from filehelper_service import ReceivedMessage, ReceivedTextMessage
self.assertEqual(ctypes.sizeof(Config), 1560)
self.assertEqual(ctypes.sizeof(TextConfig), 1688)
self.assertEqual(TextConfig.target.offset, 1560)
self.assertEqual(ctypes.sizeof(AtTextConfig), 2712)
self.assertEqual(AtTextConfig.members.offset, 1688)
self.assertEqual(ctypes.sizeof(ReceivedMessage), 1312)
self.assertEqual(ctypes.sizeof(ReceivedTextMessage), 5408)
with pefile.PE(str(TEXT_DLL)) as pe:
exports = {item.name for item in pe.DIRECTORY_ENTRY_EXPORT.symbols}
self.assertEqual(pe.FILE_HEADER.Machine, 0x8664)
self.assertTrue({b"WxSendText", b"WxSendAtText", b"WxStartTextReceiver", b"WxPollTextMessage", b"WxPollTextMessageV2",
b"WxWaitForTasks", b"WxRemoveSelfTest"}.issubset(exports))
if __name__ == "__main__":
unittest.main()