Initial import of Shirobana bot backend

Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
This commit is contained in:
RRRosmontis
2026-11-11 00:25:30 +08:00
commit 34089d0042
106 changed files with 28028 additions and 0 deletions
View File
+102
View File
@@ -0,0 +1,102 @@
import asyncio
import unittest
from pathlib import Path
import sys
from unittest.mock import patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
import agent_loop
from agent import ToolResult
class Channel:
name = "tg"
def is_admin(self, uid):
return True
class Store:
instances = []
def __init__(self):
self.steps = []
self.finished = None
Store.instances.append(self)
def start(self, uid, conversation):
return "task"
def record(self, *args):
self.steps.append(args)
def finish(self, task, status, error=""):
self.finished = status
class Executor:
calls = []
def __init__(self):
pass
def prompt_schema(self, ctx):
return []
async def execute(self, name, args, ctx):
Executor.calls.append((name, args))
if name == "setu":
return ToolResult(True, "ok", "图片已获取", artifacts=[{"id": "a1"}])
return ToolResult(True, "ok", "邮件已发送", data={"used": args})
class Engine:
def __init__(self, responses):
self.responses = list(responses)
async def call_deepseek(self, *args, **kwargs):
return self.responses.pop(0)
def parse_json_response(self, raw):
return raw
async def run_tools(self, calls):
return "web result"
class AgentLoopTest(unittest.IsolatedAsyncioTestCase):
async def test_two_round_composition_and_final_response(self):
Executor.calls = []
initial = {"message": [], "tools": [{"name": "setu", "args": {"action": "get", "params": {"tags": ["铃兰"]}}}]}
second = {"message": [], "tools": [{"name": "mail", "args": {"action": "send", "params": {"artifact_ids": ["a1"]}}}]}
final = {"message": [{"content": "完成了"}], "tools": []}
engine = Engine([second, final])
with patch.object(agent_loop, "AgentTaskStore", Store), \
patch.object(agent_loop, "ToolExecutor", Executor):
result = await agent_loop.run_agent_loop(
engine, initial, "system", "user", Channel(), "private", 1, 2)
self.assertEqual(result, final)
self.assertEqual([x[0] for x in Executor.calls], ["setu", "mail"])
self.assertEqual(Store.instances[-1].finished, "done")
self.assertEqual(len(Store.instances[-1].steps), 2)
async def test_not_understood_never_executes(self):
initial = {"understood": False, "message": [], "tools": [{"name": "mail"}]}
with patch.object(agent_loop, "ToolExecutor", Executor):
result = await agent_loop.run_agent_loop(
Engine([]), initial, "s", "u", Channel(), "private", 1, 2)
self.assertEqual(result["tools"], [])
self.assertIn("明白", result["message"][0]["content"])
self.assertEqual(Executor.calls, [])
async def test_model_failure_includes_specific_safe_error(self):
Executor.calls = []
initial = {"message": [], "tools": [{"name": "pixiv", "args": {"action": "get"}}]}
with patch.object(agent_loop, "AgentTaskStore", Store), \
patch.object(agent_loop, "ToolExecutor", Executor):
# Force an observation containing a raw-looking failure, then no model response.
original = Executor.execute
async def failure(self, name, args, ctx):
return ToolResult(False, "send_failed", "多次尝试发送失败。请手动访问 https://example.test/image")
with patch.object(Executor, "execute", failure):
result = await agent_loop.run_agent_loop(
Engine([None]), initial, "s", "u", Channel(), "private", 1, 2)
text = result["message"][0]["content"]
self.assertIn("多次尝试发送失败", text)
self.assertIn("send_failed", text)
self.assertIn("https://example.test/image", text)
if __name__ == "__main__":
unittest.main()
+417
View File
@@ -0,0 +1,417 @@
"""Isolated tests for the agent tool runtime; no real API or message delivery."""
import asyncio
import base64
import json
import os
import shlex
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
from agent import ArtifactError, ArtifactStore, CaptureChannel, ModeStore, ToolContext, ToolExecutor
import channel as channel_module
class FakeChannel:
name = "qq"
def __init__(self):
self.sent = []
async def send(self, msg_type, target_id, content):
self.sent.append((msg_type, target_id, content))
return True
class ModeStoreTests(unittest.TestCase):
def test_default_private_and_group_scopes(self):
with tempfile.TemporaryDirectory() as directory:
store = ModeStore(Path(directory) / "agent.db")
self.assertEqual(store.get_mode("qq", 10), "command")
store.set_mode("qq", 10, "agent")
store.set_mode("qq", 10, "agent", group_id=20)
store.set_mode("tg", 10, "agent")
self.assertEqual(store.get_mode("qq", 10), "agent")
self.assertEqual(store.get_mode("qq", 999, group_id=20), "agent")
self.assertEqual(store.get_mode("qq", 10, group_id=21), "command")
store.set_mode("qq", 10, "command", group_id=20)
self.assertEqual(store.get_mode("qq", 10, group_id=20), "command")
with self.assertRaises(ValueError):
store.set_mode("qq", 10, "invalid")
class ArtifactStoreTests(unittest.TestCase):
def test_local_base64_and_confined_materialize(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "artifacts"
source = Path(directory) / "source.jpg"
source.write_bytes(b"image-data")
store = ArtifactStore(root)
local = store.register(source, "image")
inline = store.register(base64.b64encode(b"second").decode(), "image", "two.jpg")
self.assertNotEqual(local["artifact_id"], inline["artifact_id"])
self.assertGreaterEqual(len(local["artifact_id"]), 16)
first_path = store.materialize(local["artifact_id"])
self.assertEqual(first_path.read_bytes(), b"image-data")
self.assertTrue(first_path.is_relative_to(root))
source.unlink()
self.assertEqual(first_path.read_bytes(), b"image-data")
self.assertNotIn(str(source), json.dumps(local))
with self.assertRaises(ArtifactError):
store.materialize("../../etc/passwd")
with self.assertRaises(ArtifactError):
store.register("/definitely/not/a/real/file")
def test_url_materializes_under_root(self):
with tempfile.TemporaryDirectory() as directory:
store = ArtifactStore(Path(directory) / "store")
artifact = store.register("https://example.invalid/a.jpg", "image")
response = mock.MagicMock()
response.__enter__.return_value = response
response.geturl.return_value = "https://example.invalid/a.jpg"
response.headers = {}
response.read.return_value = b"remote-image"
with mock.patch("urllib.request.urlopen", return_value=response) as opener:
path = store.materialize(artifact["artifact_id"])
self.assertEqual(path.read_bytes(), b"remote-image")
self.assertTrue(path.is_relative_to(store.root))
opener.assert_called_once()
def test_scoped_artifacts_require_matching_owner_and_session(self):
with tempfile.TemporaryDirectory() as directory:
store = ArtifactStore(Path(directory) / "store")
channel = SimpleNamespace(name="qq")
ctx = SimpleNamespace(channel=channel, msg_type="group", user_id=10, target_id=20)
artifact = store.register(b64("private"), "file", "private.txt", ctx=ctx)
artifact_id = artifact["artifact_id"]
self.assertEqual(store.get(artifact_id, ctx)["artifact_id"], artifact_id)
self.assertEqual(store.materialize(artifact_id, ctx).read_bytes(), b"private")
record = json.loads((store.root / artifact_id / "artifact.json").read_text())
self.assertEqual(record["owner_id"], "10")
self.assertEqual(record["session"], {
"channel": "qq", "msg_type": "group", "target_id": "20",
})
foreign_contexts = [
SimpleNamespace(channel=channel, msg_type="group", user_id=11, target_id=20),
SimpleNamespace(channel=channel, msg_type="group", user_id=10, target_id=21),
SimpleNamespace(channel=channel, msg_type="private", user_id=10, target_id=20),
SimpleNamespace(channel=SimpleNamespace(name="tg"), msg_type="group", user_id=10, target_id=20),
]
for foreign in foreign_contexts:
with self.subTest(foreign=foreign):
with self.assertRaises(ArtifactError):
store.get(artifact_id, foreign)
with self.assertRaises(ArtifactError):
store.materialize(artifact_id, foreign)
with self.assertRaises(ArtifactError):
store.get(artifact_id)
with self.assertRaises(ArtifactError):
store.materialize(artifact_id)
def test_legacy_artifacts_are_denied_to_agent_contexts(self):
with tempfile.TemporaryDirectory() as directory:
store = ArtifactStore(Path(directory) / "store")
legacy = store.register(b64("legacy"), "file")
ctx = SimpleNamespace(
channel=SimpleNamespace(name="qq"), msg_type="private",
user_id=10, target_id=10,
)
artifact_id = legacy["artifact_id"]
self.assertEqual(store.materialize(artifact_id).read_bytes(), b"legacy")
with self.assertRaises(ArtifactError):
store.get(artifact_id, ctx)
with self.assertRaises(ArtifactError):
store.materialize(artifact_id, ctx)
class ExecutorTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
self.store = ArtifactStore(self.root / "artifacts")
self.real_channel = FakeChannel()
self.sender = mock.AsyncMock(return_value=True)
self.ctx = ToolContext(
self.real_channel, "private", 9000000001, 123,
is_admin=False, request_id="req", task_id="task",
)
def tearDown(self):
self.temporary.cleanup()
def executor(self, modules):
return ToolExecutor(self.store, modules=modules, send_message=self.sender)
async def test_capture_channel_never_delivers_or_mirrors_private_text(self):
capture = CaptureChannel(self.ctx, self.store)
# Use the production sender to prove its private mirror path is skipped.
with mock.patch.object(channel_module, "_mirror_private", new=mock.AsyncMock()) as mirror:
result = await channel_module.send_message(capture, "private", 123, "captured")
self.assertFalse(result)
self.assertEqual(capture.texts, ["captured"])
self.assertEqual(self.real_channel.sent, [])
mirror.assert_not_awaited()
async def test_setu_media_is_delivered_once_and_text_is_model_facing(self):
image = self.root / "captured.jpg"
image.write_bytes(b"jpeg")
calls = []
async def handle_setu(tags, r18, ai, msg_type, target_id, ws):
from channel import send_message
calls.append((tags, r18, ai))
await send_message(ws, msg_type, target_id, "正在获取图片")
await send_message(ws, msg_type, target_id, [
{"type": "text", "data": {"text": "PID 42"}},
{"type": "image", "data": {"file": str(image)}},
])
module = SimpleNamespace(handle_setu=handle_setu)
executor = self.executor({"setu": module})
result = await executor.execute(
"setu", {"action": "get", "params": {"tags": ["猫耳"], "r18": False}}, self.ctx,
)
self.assertTrue(result.ok, result)
self.assertEqual(calls, [(["猫耳"], "不要r18", None)])
self.assertEqual(result.safe_message, "正在获取图片\nPID 42")
self.assertEqual(len(result.artifacts), 1)
self.assertEqual(result.data["deliveries"][0]["ok"], True)
self.sender.assert_awaited_once()
sent = self.sender.await_args.args[3]
self.assertEqual(sent[0]["type"], "image")
delivered = Path(sent[0]["data"]["file"].removeprefix("file://"))
self.assertTrue(delivered.is_relative_to(self.store.root))
self.assertEqual(self.real_channel.sent, [])
async def test_other_tool_text_is_not_delivered_and_mail_quotes_are_preserved(self):
seen = []
async def handle_mail(ws, msg_type, target_id, user_id, raw):
from channel import send_message
seen.append(raw)
await send_message(ws, msg_type, target_id, "邮件已发送")
executor = self.executor({"mail": SimpleNamespace(handle_mail=handle_mail)})
result = await executor.execute("mail", {
"action": "new",
"params": {"to_address": "to@example.com", "subject": "two words", "body_text": "hello world"},
}, self.ctx)
self.assertTrue(result.ok)
tokens = shlex.split(seen[0])
self.assertEqual(tokens[-3:], ["to@example.com", "two words", "hello world"])
self.assertEqual(result.safe_message, "邮件已发送")
self.sender.assert_not_awaited()
self.assertEqual(self.real_channel.sent, [])
async def test_mail_send_unknown_is_not_retryable(self):
async def handle_mail(ws, msg_type, target_id, user_id, raw):
from channel import send_message
await send_message(ws, msg_type, target_id, "发送失败:上游返回了无法识别的响应")
executor = self.executor({"mail": SimpleNamespace(handle_mail=handle_mail)})
result = await executor.execute("mail", {
"action": "send",
"params": {"to_address": "to@example.com", "subject": "s", "body_text": "b"},
}, self.ctx)
self.assertFalse(result.ok)
self.assertEqual(result.code, "send_unknown")
self.assertFalse(result.retryable)
async def test_outbound_rejects_artifact_from_another_owner(self):
foreign_ctx = ToolContext(self.real_channel, "private", self.ctx.user_id + 1, 123)
foreign = self.store.register(b64("secret"), "file", "secret.txt", ctx=foreign_ctx)
executor = self.executor({})
result = await executor.execute(
"send_file", {"artifact_id": foreign["artifact_id"]}, self.ctx,
)
self.assertFalse(result.ok)
self.assertEqual(result.code, "invalid_artifact")
self.sender.assert_not_awaited()
self.assertEqual(self.real_channel.sent, [])
async def test_mail_attachments_reject_foreign_artifact_before_sending(self):
foreign_ctx = ToolContext(self.real_channel, "private", self.ctx.user_id, 999)
foreign = self.store.register(b64("secret"), "file", "secret.txt", ctx=foreign_ctx)
send_with_attachments = mock.AsyncMock(return_value={"ok": True})
module = SimpleNamespace(
_load_user=lambda uid: {
"default": "from@rrrosmontis.icu",
"addresses": ["from@rrrosmontis.icu"],
},
send_with_attachments=send_with_attachments,
)
executor = self.executor({"mail": module})
result = await executor.execute("mail", {
"action": "send",
"params": {
"to_address": "to@example.com", "subject": "s", "body_text": "b",
"artifact_ids": [foreign["artifact_id"]],
},
}, self.ctx)
self.assertFalse(result.ok)
self.assertEqual(result.code, "invalid_artifact")
send_with_attachments.assert_not_awaited()
async def test_mail_list_limit_and_info_mid_aliases(self):
seen = []
async def handle_mail(ws, msg_type, target_id, user_id, raw):
seen.append(raw)
executor = self.executor({"mail": SimpleNamespace(handle_mail=handle_mail)})
result = await executor.execute("mail", {"action": "list", "params": {"limit": 5}}, self.ctx)
self.assertTrue(result.ok, result)
result = await executor.execute("mail", {"action": "info", "params": {"mid": 2645}}, self.ctx)
self.assertTrue(result.ok, result)
self.assertEqual(seen, [".mail list 5", ".mail info 2645"])
async def test_mail_attachments_use_new_api_and_only_artifact_paths(self):
artifact = self.store.register(b64("attachment"), "file", "a.txt", ctx=self.ctx)
calls = []
async def send_with_attachments(*args):
calls.append(args)
return {"ok": True, "attachment_count": 1}
async def handle_mail(*args):
raise AssertionError("legacy mail handler must not be called")
module = SimpleNamespace(
_load_user=lambda uid: {
"default": "from@rrrosmontis.icu",
"addresses": ["from@rrrosmontis.icu"],
},
send_with_attachments=send_with_attachments,
handle_mail=handle_mail,
)
executor = self.executor({"mail": module})
result = await executor.execute("mail", {
"action": "send",
"params": {
"to_address": "to@example.com", "subject": "s", "body_text": "b",
"artifact_ids": [artifact["artifact_id"]],
},
}, self.ctx)
self.assertTrue(result.ok, result)
self.assertEqual(result.code, "sent")
self.assertEqual(len(calls), 1)
args = calls[0]
self.assertEqual(args[:5], (
self.ctx.user_id, "from@rrrosmontis.icu", "to@example.com", "s", "b",
))
self.assertEqual(Path(args[5][0]).read_bytes(), b"attachment")
self.assertTrue(Path(args[5][0]).is_relative_to(self.store.root))
self.sender.assert_not_awaited()
async def test_permission_is_enforced_before_admin_handler_import_or_call(self):
handler = mock.AsyncMock()
executor = self.executor({"server": SimpleNamespace(handle_command=handler)})
result = await executor.execute("server", {"action": "list", "params": {}}, self.ctx)
self.assertFalse(result.ok)
self.assertEqual(result.code, "permission_denied")
handler.assert_not_awaited()
schema_names = {item["name"] for item in executor.prompt_schema(self.ctx)}
self.assertNotIn("server", schema_names)
admin_ctx = ToolContext(self.real_channel, "group", 1, 2, is_admin=True)
self.assertIn("server", {item["name"] for item in executor.prompt_schema(admin_ctx)})
async def test_send_file_accepts_only_artifact_id(self):
executor = self.executor({})
rejected = await executor.execute("send_file", {"artifact_id": "/etc/passwd"}, self.ctx)
self.assertFalse(rejected.ok)
self.assertEqual(rejected.code, "invalid_artifact")
self.sender.assert_not_awaited()
artifact = self.store.register(b64("file-body"), "file", "report.txt", ctx=self.ctx)
result = await executor.execute("send_file", {"artifact_id": artifact["artifact_id"]}, self.ctx)
self.assertTrue(result.ok, result)
content = self.sender.await_args.args[3]
self.assertEqual(content[0]["type"], "file")
self.assertNotEqual(content[0]["data"]["file"], "/etc/passwd")
self.assertTrue(Path(content[0]["data"]["file"].removeprefix("file://")).is_relative_to(self.store.root))
async def test_send_sticker_keeps_onebot_image_segment_for_qq(self):
artifact = self.store.register(b64("sticker"), "image", "sticker.webp", ctx=self.ctx)
executor = self.executor({})
result = await executor.execute(
"send_sticker", {"artifact_id": artifact["artifact_id"]}, self.ctx,
)
self.assertTrue(result.ok, result)
content = self.sender.await_args.args[3]
self.assertEqual(content[0]["type"], "image")
self.assertEqual(content[0]["data"]["sub_type"], 1)
async def test_validation_and_handler_errors_are_safe(self):
async def broken(*args):
raise RuntimeError("secret=/home/private/token")
executor = self.executor({"log": SimpleNamespace(handle_command=broken)})
admin = ToolContext(self.real_channel, "private", 1, 1, is_admin=True)
result = await executor.execute("log", {"action": "tail", "params": {"lines": 5}}, admin)
self.assertFalse(result.ok)
self.assertTrue(result.retryable)
self.assertNotIn("secret", result.safe_message)
bad = await executor.execute("log", {"action": "tail", "params": {"lines": "5;rm"}}, admin)
self.assertEqual(bad.code, "invalid_args")
unknown = await executor.execute("web_search", {}, admin)
self.assertEqual(unknown.code, "unknown_tool")
async def test_remaining_legacy_modules_are_awaited_with_safe_commands(self):
calls = []
def module(handler_name):
async def handler(*args):
calls.append((handler_name, args[-1]))
return SimpleNamespace(**{handler_name: handler})
live = SimpleNamespace(
HELP_TEXT="live help",
status_text=lambda *args: "status",
add_follow=lambda *args: calls.append(("add_follow", args)),
remove_follow=lambda *args: None,
)
modules = {
"pixiv": module("handle_pixiv"),
"jm": module("handle_jm"),
"ginkgo": module("handle_ginkgo"),
"my": module("handle_my"),
"deepseek": module("handle_command"),
"log": module("handle_command"),
"live": live,
}
executor = self.executor(modules)
admin = ToolContext(self.real_channel, "group", 1, 2, is_admin=True)
requests = [
("pixiv", "get", {"pid": 123}),
("jm", "search", {"keyword": "two words", "page": 2}),
("ginkgo", "notice", {"count": 3}),
("my", "push", {"platform": "qq", "enabled": True}),
("deepseek", "alert", {"enabled": False}),
("log", "tail", {"lines": 7}),
("live", "follow", {"room": 4, "site": 1}),
]
for name, action, params in requests:
result = await executor.execute(name, {"action": action, "params": params}, admin)
self.assertTrue(result.ok, (name, result))
self.assertEqual(calls[0], ("handle_pixiv", ".pixiv 123"))
self.assertEqual(calls[1], ("handle_jm", ".jm ss two words 2"))
self.assertEqual(calls[2], ("handle_ginkgo", ".ginkgo notice 3"))
self.assertEqual(calls[3], ("handle_my", "qq-push on"))
self.assertEqual(calls[4], ("handle_command", ".deepseek alert off"))
self.assertEqual(calls[5], ("handle_command", ".log 7"))
self.assertEqual(calls[6][0], "add_follow")
self.assertEqual(self.real_channel.sent, [])
self.sender.assert_not_awaited()
def b64(value):
"""Encode test payload without embedding large base64 literals."""
return base64.b64encode(value.encode()).decode()
if __name__ == "__main__":
unittest.main()
+88
View File
@@ -0,0 +1,88 @@
"""Akaros Mail REST and Agent tool tests; no real email is sent."""
import importlib
from pathlib import Path
import sys
import unittest
from types import SimpleNamespace
from unittest.mock import patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
from agent import ToolContext, ToolExecutor
mail = importlib.import_module("for-bot_mail")
class FakeResponse:
def __init__(self, status=201, data=None):
self.status_code = status
self.headers = {"Content-Type": "application/json"}
self.data = data or {"message": "accepted"}
def json(self):
return self.data
class FakeSession:
def __init__(self, response):
self.response = response
self.calls = []
def __enter__(self):
return self
def __exit__(self, *args):
return False
def request(self, method, url, **kwargs):
self.calls.append((method, url, kwargs))
return self.response
class AkarosMailTests(unittest.IsolatedAsyncioTestCase):
def test_send_uses_fixed_shirobana_address(self):
session = FakeSession(FakeResponse())
with patch("requests.Session", return_value=session), \
patch.object(mail, "API_KEY", "test-key"):
result = mail.send_email(
"user@example.test", "测试主题", "正文",
cc=["copy@example.test"], bcc=[])
self.assertEqual(result["message"], "accepted")
payload = session.calls[0][2]["json"]
self.assertEqual(payload["from"], "shirobana@akaros.si")
self.assertEqual(payload["to"], ["user@example.test"])
self.assertEqual(payload["cc"], ["copy@example.test"])
self.assertEqual(payload["text"], "正文")
def test_invalid_recipient_is_rejected(self):
with self.assertRaises(mail.MailInputError):
mail.send_email("not-an-email", "subject", "text")
async def test_agent_send_and_result_unknown(self):
module = SimpleNamespace(
send_email=lambda *args: {"message": "accepted"},
list_messages=lambda *args: [],
get_message=lambda value: {},
format_messages=lambda data: "没有找到邮件",
format_message=lambda data: "正文",
)
executor = ToolExecutor(modules={"akaros_mail": module})
ctx = ToolContext(channel=SimpleNamespace(name="tg"), msg_type="private", user_id=1, target_id=1)
result = await executor.execute(
"akaros_mail",
{"action": "send", "params": {"to": "user@example.test", "subject": "s", "text": "t"}},
ctx,
)
self.assertTrue(result.ok)
def fail_send(*args):
raise RuntimeError("connection lost")
module.send_email = fail_send
result = await executor.execute(
"akaros_mail",
{"action": "send", "params": {"to": "user@example.test", "subject": "s", "text": "t"}},
ctx,
)
self.assertEqual(result.code, "send_unknown")
self.assertFalse(result.retryable)
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -0,0 +1,22 @@
"""Akaros mailbox is a personal asset available without keyword activation."""
from pathlib import Path
import sys
import unittest
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
from chat import engine
class AkarosPersonaTests(unittest.TestCase):
def test_mailbox_is_personal_asset_with_autonomous_tool_selection(self):
self.assertIn("shirobana@akaros.si", engine.BASE_PROMPT)
self.assertIn("个人资产", engine.BASE_PROMPT)
self.assertIn("不要求用户特别指定服务名", engine.BASE_PROMPT)
self.assertIn("不得在没有明确任务或合理上下文时", engine.BASE_PROMPT)
if __name__ == "__main__":
unittest.main()
+67
View File
@@ -0,0 +1,67 @@
"""Expression guardrails and deterministic receipts; no live API or user data."""
from pathlib import Path
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import MagicMock
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / 'bot-utility'), str(BASE)]
import agent_loop
from agent import ToolExecutor, ToolContext
from chat import engine
class AntiDialogueStyleTests(unittest.IsolatedAsyncioTestCase):
def test_all_requested_counterexamples_and_templates_are_explicit(self):
prompt = engine.load_instruction('style_instructions.txt')
phrases = ['这句话稳', '比之前的都重', '我记下了', '我记得了', '我记住了',
'这个账我记住了', '比之前的都轻', '睡吧,我守着', '你去,我跟',
'问别人还是问自己', '行,xxx', '什么心思都写着xx上', '这句话接的漂亮',
'行,我认', '这句话我不接', 'xx比xx沉', 'xx比xx重', 'xx比xx稳', 'xx比xx轻',
'没接你的xxx', '比之前的都强', '他记得这事', '这话我留着', '很平', '稳',
'我不拦', '我跟', '回去睡', '你这句话我记着', '认你', '我不催你',
'这话是ta告诉你的,还是你自己想明白的', '认下来', '声音没抖,但比刚才稳',
'比刚才轻', '比刚才重', '比刚才平', '很直', '这话我记下了', '这话我收下了',
'说的比谁都响', '认这个', '收不回去', '认得实', 'xx就是认了', '你自己掂量',
'没接你那句xx', '不催', '你掂量']
for phrase in phrases:
with self.subTest(phrase=phrase): self.assertIn(phrase, prompt)
for principle in ('同义改写', '按共同结构识别', '即使历史里的白花曾这样说',
'直接谈具体对象', '不是可模仿', '单个汉字全部封禁', '用户既定称呼',
'不知道该怎么接', '不要评论自己如何接话'):
self.assertIn(principle, prompt)
def test_builtin_default_is_exactly_the_deployed_expression_file(self):
actual = engine.load_instruction('style_instructions.txt')
self.assertEqual(actual.strip(), engine.DEFAULT_STYLE_INSTRUCTIONS.strip())
self.assertIn('禁用:关系暗语式短应答与话语评断腔', engine.BASE_PROMPT)
def test_success_fallback_does_not_repeat_memory_acknowledgements(self):
result = {'tool':'wardrobe', 'action':'add', 'ok':True,
'data':{'item':{'id':'internal-id', 'name':'蓝色短裙', 'description':'不复述的完整细节'}}}
output = agent_loop._message_text(agent_loop._fallback_parsed([result]))
self.assertIn('蓝色短裙已经在衣柜里', output)
for phrase in ('我记', '这话', '我认', '行,', 'internal-id', '不复述的完整细节', '{'):
self.assertNotIn(phrase, output)
def test_polishing_summary_describes_saved_outcome_not_relationship(self):
output = agent_loop._polish_facts([{'tool':'wardrobe', 'action':'add', 'ok':True,
'data':{'item':{'id':'internal', 'name':'蓝色短裙'}}}])
self.assertIn('保存衣物到衣柜', output)
self.assertNotIn('记住衣物', output)
async def test_tool_receipt_is_factual_not_a_memory_performance(self):
item = {'id':'123456789abc', 'name':'蓝色短裙', 'category':'短裙', 'description':'白色环绕条纹'}
store = SimpleNamespace(add=lambda *args: item)
executor = ToolExecutor(artifacts=MagicMock(), modules={'wardrobe':SimpleNamespace(wardrobe=store)})
ctx = ToolContext(SimpleNamespace(name='tg'), 'private', 42, 42)
result = await executor.execute('wardrobe', {'action':'add','params':{'name':'蓝色短裙'}}, ctx)
self.assertTrue(result.ok)
self.assertIn('已保存到衣柜', result.safe_message)
self.assertNotIn('记住', result.safe_message)
self.assertEqual(result.data['item']['description'], '白色环绕条纹')
if __name__ == '__main__':
unittest.main()
+119
View File
@@ -0,0 +1,119 @@
"""Bilibili OpenAPI command tests; no real network or message delivery."""
import asyncio
from pathlib import Path
import sys
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
import bilibili
class FakeResponse:
def __init__(self, status=200, content_type="video/mp4", chunks=(b"video",), text=""):
self.status_code = status
self.headers = {"Content-Type": content_type}
self._chunks = chunks
self.text = text
self.closed = False
def iter_content(self, size):
yield from self._chunks
def close(self):
self.closed = True
class FakeChannel:
name = "tg"
@staticmethod
def is_admin(uid):
return False
class BilibiliTests(unittest.IsolatedAsyncioTestCase):
def test_sanitize_bv(self):
self.assertEqual(bilibili.sanitize_bv("bv1uT4y1P7CX"), "BV1uT4y1P7CX")
self.assertEqual(bilibili.sanitize_bv(" BV1uT4y1P7CX "), "BV1uT4y1P7CX")
self.assertIsNone(bilibili.sanitize_bv("1uT4y1P7CX"))
self.assertIsNone(bilibili.sanitize_bv("https://www.bilibili.com/video/BV1uT4y1P7CX"))
self.assertIsNone(bilibili.sanitize_bv("BV1uT4y1P7CX extra"))
async def test_handle_sends_one_file_and_cleans_temp(self):
with tempfile.TemporaryDirectory() as directory:
video = Path(directory) / "video.mp4"
video.write_bytes(b"video")
sender = AsyncMock(return_value=True)
with patch("channel.send_message", new=sender), \
patch.object(bilibili, "download_video", return_value=video):
await bilibili.handle_bilibili(
FakeChannel(), "private", 10001, 1, ".bilibili BV1uT4y1P7CX"
)
self.assertEqual(sender.await_count, 2)
segments = sender.await_args.args[3]
self.assertEqual(len(segments), 1)
self.assertEqual(segments[0]["type"], "file")
self.assertTrue(segments[0]["data"]["file"].startswith("file://"))
self.assertIn("bv=BV1uT4y1P7CX", segments[0]["data"]["manual"])
self.assertFalse(video.exists())
async def test_invalid_input_does_not_call_openapi(self):
sender = AsyncMock(return_value=True)
with patch("channel.send_message", new=sender), \
patch.object(bilibili, "download_video") as download:
await bilibili.handle_bilibili(FakeChannel(), "private", 10001, 1, ".bilibili nope")
download.assert_not_called()
self.assertIn("格式", sender.await_args.args[3])
async def test_openapi_error_is_reported_and_temp_is_cleaned(self):
sender = AsyncMock(return_value=True)
with patch("channel.send_message", new=sender), \
patch.object(bilibili, "download_video", side_effect=RuntimeError("OpenAPI HTTP 502")):
await bilibili.handle_bilibili(
FakeChannel(), "private", 10001, 1, ".bilibili BV1uT4y1P7CX"
)
self.assertIn("OpenAPI HTTP 502", sender.await_args.args[3])
def test_download_writes_stream_and_closes_response(self):
response = FakeResponse(chunks=(b"abc", b"def"))
with tempfile.TemporaryDirectory() as directory, \
patch.object(bilibili, "CACHE_DIR", Path(directory)), \
patch("requests.get", return_value=response) as get:
path = bilibili.download_video("BV1uT4y1P7CX")
try:
self.assertEqual(path.read_bytes(), b"abcdef")
self.assertEqual(path.suffix, ".mp4")
get.assert_called_once_with(
bilibili.OPENAPI_URL,
params={"bv": "BV1uT4y1P7CX"},
stream=True,
timeout=bilibili.HTTP_TIMEOUT,
)
self.assertTrue(response.closed)
finally:
bilibili.cleanup_file(path)
def test_download_http_error_includes_detail_and_closes_response(self):
response = FakeResponse(
status=502,
content_type="application/json",
chunks=(),
text='{"error":"download failed"}',
)
with tempfile.TemporaryDirectory() as directory, \
patch.object(bilibili, "CACHE_DIR", Path(directory)), \
patch("requests.get", return_value=response):
with self.assertRaisesRegex(RuntimeError, "download failed"):
bilibili.download_video("BV1uT4y1P7CX")
self.assertTrue(response.closed)
self.assertEqual(list(Path(directory).iterdir()), [])
if __name__ == "__main__":
unittest.main()
+380
View File
@@ -0,0 +1,380 @@
import asyncio
import hashlib
import json
import os
import sqlite3
import sys
import tempfile
import threading
import types
import unittest
from pathlib import Path
from unittest import mock
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
# These tests never perform HTTP. Keep them offline on minimal Python installs.
try:
import aiohttp # noqa: F401
except ModuleNotFoundError:
aiohttp_stub = types.ModuleType("aiohttp")
class OfflineClientSession:
def __init__(self, *args, **kwargs):
raise RuntimeError("HTTP is disabled in offline chat migration tests")
aiohttp_stub.ClientSession = OfflineClientSession
sys.modules["aiohttp"] = aiohttp_stub
from chat import engine
import migrate_chat_data
DEFAULT_ENGINE_PATHS = {
"history": engine.HISTORY_DIR,
"persona": engine.PROMPT_FILE,
"lorebook": engine.LOREBOOK_PATH,
}
class RecordingChannel:
def __init__(self, name="tg"):
self.name = name
self.sent = []
async def send(self, msg_type, target_id, content):
self.sent.append((msg_type, target_id, content))
return True
class EngineTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="chat-engine-test-")
root = Path(self.tmp.name)
self.paths = {
"BASE_DIR": str(root),
"HISTORY_DIR": str(root / "history"),
"GROUP_HISTORY_DIR": str(root / "history" / "group"),
"MEMORY_DIR": str(root / "memory"),
"STATUS_DIR": str(root / "status"),
"SETTING_FILE": str(root / "setting.json"),
"STICKERS_DIR": str(root / "stickers"),
"PROMPTS_DIR": str(root / "prompts"),
"LOREBOOK_PATH": str(root / "prompts" / "lorebook.json"),
}
self.path_patch = mock.patch.multiple(engine, **self.paths)
self.path_patch.start()
for key in ("HISTORY_DIR", "GROUP_HISTORY_DIR", "MEMORY_DIR", "STATUS_DIR", "STICKERS_DIR", "PROMPTS_DIR"):
os.makedirs(self.paths[key], exist_ok=True)
engine._delivery_targets.clear()
engine._user_channel.clear()
engine._history_revisions.clear()
engine._websocket = None
def tearDown(self):
engine._delivery_targets.clear()
engine._user_channel.clear()
engine._history_revisions.clear()
engine._websocket = None
self.path_patch.stop()
self.tmp.cleanup()
def test_package_paths_and_prompt_defaults(self):
package = Path(engine.__file__).resolve().parent
self.assertEqual(Path(DEFAULT_ENGINE_PATHS["history"]), package / "history")
self.assertEqual(Path(DEFAULT_ENGINE_PATHS["persona"]), package.parent / "prompts" / "persona.txt")
self.assertEqual(Path(DEFAULT_ENGINE_PATHS["lorebook"]), package.parent / "prompts" / "lorebook.json")
# The temporary prompt directory is empty: built-in defaults must work.
self.assertIn("JSON", engine.load_instruction("agent_instructions.txt"))
self.assertIn("群聊", engine.load_instruction("group_instructions.txt"))
Path(engine.PROMPTS_DIR, "compact_instructions.txt").write_text("自定义压缩指令", encoding="utf-8")
self.assertEqual(engine.load_instruction("compact_instructions.txt"), "自定义压缩指令")
def test_private_model_request_has_no_legacy_debounce(self):
self.assertEqual(engine.USER_IDLE_WAIT_MIN, 0)
self.assertEqual(engine.USER_IDLE_WAIT_MAX, 0)
def test_explicit_delivery_never_uses_uid_as_target(self):
uid = 9000000001
channel = RecordingChannel()
engine.set_delivery(uid, channel, 10000000042)
ok = asyncio.run(engine.send_private_message(None, uid, "你好。"))
self.assertTrue(ok)
self.assertEqual(channel.sent, [("private", 10000000042, "你好")])
self.assertNotEqual(channel.sent[0][1], uid)
def test_restart_recovers_one_identity_target_without_mirror_fanout(self):
uid = 9000000001
channel = RecordingChannel()
engine.set_websocket(channel) # only channel registry fallback; no delivery binding survives
with mock.patch("identity.push_target", return_value=("tg", 10000000042)) as preferred, \
mock.patch("identity.push_targets", return_value=[("tg", 10000000042), ("qq", 12345)]) as fanout:
ok = asyncio.run(engine.send_private_message(None, uid, "单条投递"))
self.assertTrue(ok)
preferred.assert_called_once_with(uid)
fanout.assert_not_called()
self.assertEqual(channel.sent, [("private", 10000000042, "单条投递")])
def test_qq_only_identity_uses_first_target_only(self):
uid = 9000000001
channel = RecordingChannel("qq")
engine.set_websocket(channel)
with mock.patch("identity.push_target", return_value=None), \
mock.patch("identity.push_targets", return_value=[("qq", 12345), ("qq", 67890)]):
asyncio.run(engine.send_private_message(None, uid, "QQ"))
self.assertEqual(channel.sent, [("private", 12345, "QQ")])
def test_unmapped_uid_fails_closed(self):
channel = RecordingChannel()
with mock.patch("identity.push_target", return_value=None), \
mock.patch("identity.push_targets", return_value=[]):
ok = asyncio.run(engine.send_private_message(channel, 9000000001, "不应发送"))
self.assertFalse(ok)
self.assertEqual(channel.sent, [])
def test_compact_cas_does_not_drop_concurrent_append(self):
uid = 9000000001
path = Path(engine.HISTORY_DIR) / (str(uid) + ".txt")
original = ["[2026-01-01 00:00:0%d][user] line-%d" % (i, i) for i in range(5)]
path.write_text("\n".join(original) + "\n", encoding="utf-8")
async def model_with_append(*args, **kwargs):
engine.append_history(uid, "user", "concurrent-new-line")
return "摘要"
with mock.patch.object(engine, "call_deepseek", side_effect=model_with_append):
result = asyncio.run(engine.compact_history(uid, keep_recent=2))
self.assertIn("未改写历史", result)
lines = path.read_text(encoding="utf-8").splitlines()
self.assertEqual(lines[:5], original)
self.assertIn("concurrent-new-line", lines[-1])
self.assertEqual(len(lines), 6)
async def model(*args, **kwargs):
return "摘要"
with mock.patch.object(engine, "call_deepseek", side_effect=model):
result = asyncio.run(engine.compact_history(uid, keep_recent=2))
self.assertIn("已完成", result)
lines = path.read_text(encoding="utf-8").splitlines()
self.assertIn("[summary]", lines[0])
self.assertEqual(len(lines), 3)
self.assertIn("concurrent-new-line", lines[-1])
self.assertEqual(list(Path(engine.HISTORY_DIR).glob(".chat-*.tmp")), [])
def test_status_read_modify_write_uses_uid_lock(self):
uid = 9000000001
barrier = threading.Barrier(9)
def worker(index):
barrier.wait()
if index % 2:
engine.update_user_status(uid, pause=True)
else:
engine.update_user_status(uid, balance_notice_date="2026-10-29")
threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
for thread in threads:
thread.start()
barrier.wait()
for thread in threads:
thread.join()
status = engine.load_user_status(uid)
self.assertTrue(status["pause"])
self.assertEqual(status["balance_notice_date"], "2026-10-29")
def test_modelscope_counter_does_not_lose_whole_file_updates(self):
workers = [threading.Thread(target=engine._modelscope_mark_success) for _ in range(12)]
for thread in workers:
thread.start()
for thread in workers:
thread.join()
self.assertEqual(engine.load_setting()["modelscope_count"], 12)
def test_group_prompt_and_reply_never_read_private_memory(self):
response = json.dumps({"message": [{"content": "群回复"}]}, ensure_ascii=False)
with mock.patch.object(engine, "call_deepseek", new=mock.AsyncMock(return_value=response)) as model, \
mock.patch.object(engine, "send_group_message", new=mock.AsyncMock()), \
mock.patch.object(engine.memory_store, "migrate_legacy") as migrate, \
mock.patch.object(engine.memory_store, "recall_facts") as recall, \
mock.patch.object(engine.memory_store, "get_relation") as relation, \
mock.patch.object(engine.memory_store, "list_entities") as entities:
asyncio.run(engine._call_group_model_and_reply_impl(None, 1000000000999, "大家好"))
migrate.assert_not_called()
recall.assert_not_called()
relation.assert_not_called()
entities.assert_not_called()
prompt = model.await_args.args[0]
self.assertIn("不要读取、推断或泄露私聊", prompt)
class MigrationTestCase(unittest.TestCase):
UID = 9000000001
TG_NS = 10000000042
QQ = 12345
GROUP = 1000000000999
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="chat-migration-test-")
root = Path(self.tmp.name)
self.source = root / "old-chat"
self.accounts = root / "accounts"
self.dest = root / "new-chat"
self.report = root / "report.json"
for directory in (self.source, self.accounts / "users"):
directory.mkdir(parents=True, exist_ok=True)
record = {
"uid": self.UID,
"tg": {"raw": 42, "ns": self.TG_NS},
"qq": [{"id": self.QQ}],
}
(self.accounts / "users" / (str(self.UID) + ".json")).write_text(
json.dumps(record), encoding="utf-8")
for category in ("history/group", "memory", "status", "stickers"):
(self.source / category).mkdir(parents=True, exist_ok=True)
(self.source / "history" / (str(self.TG_NS) + ".txt")).write_text(
"shared\ntg-only\n", encoding="utf-8")
(self.source / "history" / (str(self.QQ) + ".txt")).write_text(
"shared\nqq-only\n", encoding="utf-8")
(self.source / "history" / "777.txt").write_text("orphan\n", encoding="utf-8")
(self.source / "history" / "group" / (str(self.GROUP) + ".txt")).write_text(
"group-line\n", encoding="utf-8")
(self.source / "memory" / (str(self.TG_NS) + ".txt")).write_text("tg-memory\n", encoding="utf-8")
(self.source / "memory" / (str(self.QQ) + ".txt")).write_text("qq-memory\n", encoding="utf-8")
(self.source / "status" / (str(self.TG_NS) + ".json")).write_text(json.dumps({
"pause": True, "nested": {"tg": 1},
}), encoding="utf-8")
(self.source / "status" / (str(self.QQ) + ".json")).write_text(json.dumps({
"sleep": True, "nested": {"qq": 2},
}), encoding="utf-8")
(self.source / "status" / "777.json").write_text("{}", encoding="utf-8")
(self.source / "setting.json").write_text(json.dumps({"chat": True}), encoding="utf-8")
(self.source / "stickers" / "ok.webp").write_bytes(b"sticker")
self._make_source_db()
self.before = self._tree_hashes(self.source)
def tearDown(self):
self.tmp.cleanup()
@staticmethod
def _tree_hashes(root):
return {
str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest()
for path in sorted(root.rglob("*")) if path.is_file()
}
def _make_source_db(self):
con = sqlite3.connect(str(self.source / "memory.db"))
con.executescript(migrate_chat_data.SCHEMA)
for old_uid, content in ((self.TG_NS, "shared fact"), (self.QQ, "shared fact"),
(self.QQ, "qq fact"), (777, "orphan fact")):
con.execute(
"INSERT INTO facts(uid,content,importance,keywords,created_at,archived,source)"
" VALUES(?,?,3,'',1,0,'test')", (old_uid, content))
for old_uid, obj in ((self.TG_NS, "cat"), (self.QQ, "dog"), (777, "orphan")):
con.execute(
"INSERT INTO triples(uid,subject,predicate,object,importance,valid_from,valid_until,created_at,source)"
" VALUES(?,?, 'likes', ?,3,1,NULL,1,'test')", (old_uid, "user", obj))
con.execute("INSERT INTO relations VALUES(?,?,?,?,?,?)", (
self.TG_NS, 10, 2, 50, 20, json.dumps([{"ts": 1, "text": "tg"}]),
))
con.execute("INSERT INTO relations VALUES(?,?,?,?,?,?)", (
self.QQ, 5, 3, 60, 30, json.dumps([{"ts": 2, "text": "qq"}]),
))
con.execute("INSERT INTO relations VALUES(?,?,?,?,?,?)", (777, 1, 9, 1, 1, "[]"))
con.execute("INSERT INTO meta VALUES('imported:%s','1')" % self.TG_NS)
con.execute("INSERT INTO meta VALUES('custom','value')")
con.commit()
con.close()
def _counts(self):
con = sqlite3.connect(str(self.dest / "memory.db"))
result = {
table: con.execute("SELECT COUNT(*) FROM %s" % table).fetchone()[0]
for table in ("facts", "triples", "relations")
}
result["relation_count"] = con.execute(
"SELECT msg_count FROM relations WHERE uid=?", (self.UID,)).fetchone()[0]
result["first_met"] = con.execute(
"SELECT first_met FROM relations WHERE uid=?", (self.UID,)).fetchone()[0]
result["fts"] = con.execute("SELECT COUNT(*) FROM facts_fts").fetchone()[0]
result["fts_hits"] = con.execute(
"SELECT COUNT(*) FROM facts_fts WHERE facts_fts MATCH 'shared'").fetchone()[0]
con.close()
return result
def test_full_migration_quarantine_and_idempotency(self):
report = migrate_chat_data.migrate(
self.source, self.accounts, self.dest, self.report)
self.assertTrue(self.report.is_file())
self.assertEqual(report["mapping"]["tg_ns_to_uid"], {str(self.TG_NS): self.UID})
self.assertEqual(report["mapping"]["old_key_to_uid"][str(self.QQ)], self.UID)
history = (self.dest / "history" / (str(self.UID) + ".txt")).read_text(encoding="utf-8")
self.assertEqual(history.splitlines(), ["shared", "tg-only", "qq-only"])
memory = (self.dest / "memory" / (str(self.UID) + ".txt")).read_text(encoding="utf-8")
self.assertEqual(set(memory.splitlines()), {"tg-memory", "qq-memory"})
status = json.loads((self.dest / "status" / (str(self.UID) + ".json")).read_text(encoding="utf-8"))
self.assertEqual(status, {"pause": True, "sleep": True, "nested": {"qq": 2, "tg": 1}})
self.assertEqual((self.dest / "history" / "group" / (str(self.GROUP) + ".txt")).read_text(
encoding="utf-8"), "group-line\n")
self.assertFalse((self.dest / "history" / "777.txt").exists())
self.assertTrue((self.dest / "quarantine" / "history" / "777.txt").is_file())
self.assertTrue((self.dest / "quarantine" / "status" / "777.json").is_file())
self.assertEqual((self.dest / "stickers" / "ok.webp").read_bytes(), b"sticker")
self.assertTrue(report["sqlite"]["fts_validated"])
self.assertEqual(report["sqlite"]["unmatched"], {"facts": 1, "triples": 1, "relations": 1})
self.assertEqual(self._counts(), {
"facts": 2, "triples": 2, "relations": 1, "relation_count": 5,
"first_met": 5, "fts": 2, "fts_hits": 1,
})
self.assertEqual(self._tree_hashes(self.source), self.before, "source was modified")
# A second run must not duplicate lines/facts/triples or add relation counts again.
migrate_chat_data.migrate(self.source, self.accounts, self.dest, self.report)
self.assertEqual(self._counts(), {
"facts": 2, "triples": 2, "relations": 1, "relation_count": 5,
"first_met": 5, "fts": 2, "fts_hits": 1,
})
self.assertEqual((self.dest / "history" / (str(self.UID) + ".txt")).read_text(
encoding="utf-8"), history)
self.assertEqual(self._tree_hashes(self.source), self.before, "source changed on rerun")
def test_existing_destination_values_win_json_conflicts(self):
(self.dest / "status").mkdir(parents=True)
(self.dest / "status" / (str(self.UID) + ".json")).write_text(json.dumps({
"pause": False, "custom": "new",
}), encoding="utf-8")
report = migrate_chat_data.migrate(self.source, self.accounts, self.dest, self.report)
status = json.loads((self.dest / "status" / (str(self.UID) + ".json")).read_text(encoding="utf-8"))
self.assertFalse(status["pause"])
self.assertEqual(status["custom"], "new")
self.assertTrue(status["sleep"])
self.assertTrue(any(item["kind"] == "json_value" for item in report["conflicts"]))
class RawTelegramMigrationTest(unittest.TestCase):
def test_explicit_raw_tg_provisioning_avoids_qq_guess(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source, accounts, dest = root / "source", root / "accounts", root / "dest"
(source / "history").mkdir(parents=True)
accounts.mkdir(parents=True)
(source / "history" / "123.txt").write_text("legacy\n", encoding="utf-8")
con = sqlite3.connect(str(source / "memory.db"))
con.executescript(migrate_chat_data.SCHEMA)
con.execute("INSERT INTO facts(uid,content,importance,keywords,created_at,archived,source) VALUES(123,'fact',3,'',1,0,'test')")
con.commit()
con.close()
report = migrate_chat_data.migrate(
source, accounts, dest, raw_tg=True, provision_tg=True)
self.assertEqual(len(report["provisioned_tg"]), 1)
uid = report["provisioned_tg"][0]["uid"]
self.assertEqual(report["provisioned_tg"][0]["tg_ns"], 10000000123)
self.assertTrue((dest / "history" / (str(uid) + ".txt")).is_file())
self.assertEqual(report["sqlite"]["unmatched"]["facts"], 0)
self.assertTrue((accounts / "users" / (str(uid) + ".json")).is_file())
if __name__ == "__main__":
unittest.main()
+147
View File
@@ -0,0 +1,147 @@
"""Regression tests for chat API, permissions, concurrency and cleanup."""
import asyncio
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import AsyncMock, patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
from chat import engine
class FakeResponse:
def __init__(self, status, payload=None):
self.status = status
self.payload = payload or {}
async def json(self):
return self.payload
async def text(self):
return json.dumps(self.payload)
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
class FakeSession:
def __init__(self, responses):
self.responses = responses
self.payloads = []
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
def post(self, url, **kwargs):
self.payloads.append(kwargs["json"])
response = self.responses.pop(0)
if isinstance(response, Exception):
raise response
return response
class ChatRegressionTests(unittest.IsolatedAsyncioTestCase):
async def test_official_retry_preserves_multimodal_and_reasoning(self):
responses = [
FakeResponse(500, {"error": "temporary"}),
FakeResponse(200, {"choices": [{"message": {"content": "ok"}}]}),
]
sessions = []
def factory():
session = FakeSession(responses)
sessions.append(session)
return session
content = [{"type": "text", "text": "task"}]
with patch.object(engine.aiohttp, "ClientSession", side_effect=factory), \
patch.object(engine.asyncio, "sleep", new=AsyncMock()):
result = await engine._call_deepseek_official(
"system", content, 0, presence_penalty=0.3, reasoning=True)
self.assertEqual(result, "ok")
self.assertEqual(len(sessions), 2)
for session in sessions:
self.assertEqual(session.payloads[0]["messages"][1]["content"], content)
self.assertEqual(session.payloads[0]["thinking"], {"type": "enabled"})
self.assertEqual(session.payloads[0]["presence_penalty"], 0.3)
async def test_json_structure_is_validated(self):
valid = '{"message":[{"content":"ok"}]}'
self.assertIsNotNone(engine.parse_json_response(valid))
for raw in ('{"message":["x"]}', '{"message":[null]}',
'{"message":[{}]}', '{"message":[{"content":1}]}'):
self.assertIsNone(engine.parse_json_response(raw))
async def test_mixed_group_batch_disables_actor_tools(self):
engine._group_pending_messages[1] = ["普通用户请求", "管理员插话"]
engine._group_pending_users[1] = [2, 1]
engine._group_pending_images[1] = []
reply = AsyncMock()
with patch.object(engine, "_call_group_model_and_reply", new=reply), \
patch.object(engine.random, "uniform", return_value=0):
await engine._process_group_messages(1)
self.assertEqual(reply.await_args.args[3], 0)
async def test_private_turns_are_serialized(self):
active = 0
maximum = 0
async def impl(*args, **kwargs):
nonlocal active, maximum
active += 1
maximum = max(maximum, active)
await asyncio.sleep(0.02)
active -= 1
center = type("Center", (), {
"begin_conversation": lambda self, uid: None,
"end_conversation": lambda self, uid: None,
"deliver_due": AsyncMock(),
})()
import notifications
with patch.object(engine, "_resolve_delivery", return_value=None), \
patch.object(engine, "_call_model_and_reply_impl", new=impl), \
patch.object(notifications, "center", center):
await asyncio.gather(*[
engine._call_model_and_reply(None, 1, f"message-{i}", False)
for i in range(3)
])
self.assertEqual(maximum, 1)
async def test_reasoning_json_gets_one_repair_retry(self):
valid = '{"message":[{"content":"ok"}],"tools":[{"name":"web_search","args":{"query":"x"}}]}'
model = AsyncMock(side_effect=["not json", valid])
with patch.object(engine, "call_deepseek", new=model):
parsed = await engine._reasoning_json_response("system", "user")
self.assertEqual(parsed["tools"][0]["name"], "web_search")
self.assertEqual(model.await_count, 2)
self.assertTrue(all(call.kwargs["reasoning"] for call in model.await_args_list))
async def test_reasoning_json_stops_after_two_invalid_responses(self):
model = AsyncMock(side_effect=["bad", "still bad"])
with patch.object(engine, "call_deepseek", new=model):
parsed = await engine._reasoning_json_response("system", "user")
self.assertIsNone(parsed)
self.assertEqual(model.await_count, 2)
async def test_send_segments_reports_delivery_failure(self):
with patch.object(engine, "send_private_message", new=AsyncMock(side_effect=[True, False])):
ok = await engine.send_message_segments(None, 1, [{"content": "a"}, {"content": "b"}])
self.assertFalse(ok)
async def test_pending_images_are_cleaned_after_consumption(self):
with tempfile.NamedTemporaryFile(delete=False) as handle:
path = Path(handle.name)
engine._user_pending_messages[1] = ["读图"]
engine._user_pending_images[1] = [{"url": str(path)}]
with patch.object(engine, "_call_model_and_reply", new=AsyncMock()):
await engine._process_pending_messages(1, 0)
self.assertFalse(path.exists())
if __name__ == "__main__":
unittest.main()
+53
View File
@@ -0,0 +1,53 @@
"""Credential assignments stay configurable without importing live services."""
import ast
import os
from pathlib import Path
import secrets
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
MAPPINGS = (
('bot-utility/for_admin_komari_agent.py', 'KOMARI_PASSWORD', 'KOMARI_PASSWORD'),
('bot-utility/pixiv.py', 'CF_PASSWORD', 'PIXIV_CF_PASSWORD'),
('mail-bridge/mailWeb.py', 'HMAIL_ADMIN_PASSWORD', 'HMAIL_ADMIN_PASSWORD'),
('mail-bridge/mailWeb.py', 'REGISTER_AUTH_CODE', 'MAIL_REGISTER_AUTH_CODE'),
('mail-bridge/mailWeb.py', 'app.secret_key', 'MAIL_FLASK_SECRET_KEY'),
)
def expression(relative, target):
tree = ast.parse((ROOT / relative).read_text(encoding='utf-8'))
for node in tree.body:
if isinstance(node, ast.Assign) and any(ast.unparse(t) == target for t in node.targets):
return ast.Expression(node.value)
raise AssertionError('Credential assignment missing: ' + target)
class EnvironmentCredentialTests(unittest.TestCase):
def test_each_credential_comes_from_its_environment_variable(self):
for path, target, env in MAPPINGS:
with self.subTest(target=target), patch.dict(os.environ, {env: 'temporary-unit-test-value'}):
value = eval(compile(expression(path, target), '<credential-assignment>', 'eval'),
{'os': os, 'secrets': secrets})
self.assertEqual(value, 'temporary-unit-test-value')
def test_missing_passwords_default_empty_not_embedded_credentials(self):
for path, target, _ in MAPPINGS[:-1]:
with self.subTest(target=target), patch.dict(os.environ, {}, clear=True):
value = eval(compile(expression(path, target), '<credential-assignment>', 'eval'),
{'os': os, 'secrets': secrets})
self.assertEqual(value, '')
def test_unconfigured_flask_key_is_ephemeral_not_shared(self):
path, target, _ = MAPPINGS[-1]
with patch.dict(os.environ, {}, clear=True):
code = compile(expression(path, target), '<credential-assignment>', 'eval')
one = eval(code, {'os': os, 'secrets': secrets})
two = eval(code, {'os': os, 'secrets': secrets})
self.assertTrue(one)
self.assertNotEqual(one, two)
if __name__ == '__main__':
unittest.main()
+80
View File
@@ -0,0 +1,80 @@
"""Telegram Help must retain inline buttons in both command and chat modes."""
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
import main
class FakeTGChannel:
tg_username = "test_bot"
@staticmethod
def is_admin(uid):
return True
def private_message(text="/help"):
return SimpleNamespace(
from_user=SimpleNamespace(id=123, is_bot=False),
chat=SimpleNamespace(id=123, type="private"),
text=text,
caption=None,
photo=[],
entities=[],
)
class TelegramHelpTests(unittest.IsolatedAsyncioTestCase):
async def run_root_help(self, mode):
bot = SimpleNamespace(send_message=AsyncMock(), id=999)
store = SimpleNamespace(get_mode=lambda *args: mode)
with patch.object(main, "ModeStore", return_value=store), \
patch.object(main.identity, "resolve", return_value=1), \
patch.object(main.help_ui, "chat_help", new=AsyncMock(return_value="chat help")) as chat_help, \
patch.object(main, "handle_message", new=AsyncMock()) as dispatch:
await main.make_tg_handler(FakeTGChannel())(private_message(), bot)
return bot, chat_help, dispatch
async def test_command_root_help_has_buttons(self):
bot, chat_help, dispatch = await self.run_root_help("command")
kwargs = bot.send_message.await_args.kwargs
self.assertIsNotNone(kwargs.get("reply_markup"))
self.assertIn("服务列表", bot.send_message.await_args.args[1])
chat_help.assert_not_awaited()
dispatch.assert_not_awaited()
async def test_chat_root_help_has_buttons_and_skips_legacy_dispatch(self):
bot, chat_help, dispatch = await self.run_root_help("chat")
kwargs = bot.send_message.await_args.kwargs
self.assertIsNotNone(kwargs.get("reply_markup"))
self.assertEqual(bot.send_message.await_args.args[1], "chat help")
chat_help.assert_awaited_once_with(1, None, True)
dispatch.assert_not_awaited()
async def test_chat_help_callback_keeps_buttons_and_mode_aware_content(self):
bot = SimpleNamespace(send_message=AsyncMock())
callback = SimpleNamespace(
from_user=SimpleNamespace(id=123),
message=SimpleNamespace(chat=SimpleNamespace(id=123, type="private")),
data="help:1",
answer=AsyncMock(),
)
store = SimpleNamespace(get_mode=lambda *args: "chat")
with patch.object(main, "ModeStore", return_value=store), \
patch.object(main.identity, "resolve", return_value=1), \
patch.object(main.help_ui, "chat_help", new=AsyncMock(return_value="setu chat help")) as chat_help:
await main.make_tg_help_callback(FakeTGChannel())(callback, bot)
chat_help.assert_awaited_once_with(1, "setu", True)
self.assertEqual(bot.send_message.await_args.args[1], "setu chat help")
self.assertIsNotNone(bot.send_message.await_args.kwargs.get("reply_markup"))
if __name__ == "__main__":
unittest.main()
+95
View File
@@ -0,0 +1,95 @@
"""Komari notifications and natural-style prompt integration tests."""
from pathlib import Path
import sys
import unittest
from unittest.mock import AsyncMock, patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
import for_admin_komari_agent as komari
import help_ui
import notification_render
import notifications
from chat import engine
class KomariNotificationTests(unittest.IsolatedAsyncioTestCase):
async def test_komari_alert_enters_structured_notification_pipeline(self):
with patch("identity.admin_uids", return_value=[1]), \
patch("identity.push_targets", return_value=[("tg", 10001)]), \
patch.object(notifications.center, "publish", return_value="event-1") as publish:
accepted = await komari._notify_admins(
"[Komari] 服务器「Ama-10」当前处于离线状态。",
node_uuid="node-1",
server="Ama-10",
status="offline",
)
self.assertTrue(accepted)
event = publish.call_args.args[0]
self.assertEqual(event.kind, "komari")
self.assertEqual(event.uid, 1)
self.assertEqual(event.payload["server"], "Ama-10")
self.assertEqual(event.payload["status"], "offline")
self.assertIn("当前处于离线状态", event.legacy_text)
async def test_komari_alert_without_targets_does_not_advance(self):
with patch("identity.admin_uids", return_value=[1]), \
patch("identity.push_targets", return_value=[]), \
patch.object(notifications.center, "publish") as publish:
accepted = await komari._notify_admins(
"offline", node_uuid="node-1", server="Ama-10", status="offline"
)
self.assertFalse(accepted)
publish.assert_not_called()
async def test_chat_renderer_can_naturalize_komari_event(self):
event = notifications.Event(
kind="komari",
dedupe_key="komari:test",
legacy_text="[Komari] 服务器「Ama-10」当前处于离线状态。",
uid=1,
payload={"server": "Ama-10", "status": "offline", "source": "Komari"},
)
with patch.object(engine, "call_deepseek", new=AsyncMock(return_value="Ama-10 掉线了。")) as model:
rendered = await notification_render.render_event(event, "chat", 10001)
self.assertEqual(rendered, "Ama-10 掉线了。")
self.assertIn("自然表达补充", model.await_args.args[0])
class StylePromptTests(unittest.IsolatedAsyncioTestCase):
def test_base_prompt_includes_natural_style_supplement(self):
self.assertIn("自然表达补充", engine.BASE_PROMPT)
self.assertIn("话不算多,先把", engine.BASE_PROMPT)
self.assertIn("我先跟你说一声", engine.BASE_PROMPT)
self.assertIn("不过还不能", engine.BASE_PROMPT)
self.assertIn("已知且与当前回答直接相关的客观限制", engine.BASE_PROMPT)
self.assertIn("不影响 JSON", engine.BASE_PROMPT)
def test_active_prompt_has_varied_events_without_waiting_for_user(self):
prompt = engine.load_instruction("notification_instructions.txt")
self.assertIn("随机发生的生活感", prompt)
self.assertIn("不是从不出门", prompt)
self.assertIn("生活只围着用户转", prompt)
self.assertIn("不必每条都关心用户", prompt)
async def test_help_no_longer_requires_self_introduction(self):
with patch.object(engine, "call_deepseek", new=AsyncMock(return_value="能做的事如下")) as model:
result = await help_ui.chat_help(1)
self.assertEqual(result, "能做的事如下")
prompt = model.await_args.args[0]
self.assertIn("不必自我介绍", prompt)
self.assertNotIn("必须先简短介绍自己", prompt)
async def test_help_fallback_also_avoids_self_introduction_and_preview(self):
with patch.object(engine, "call_deepseek", new=AsyncMock(return_value="")):
result = await help_ui.chat_help(1)
self.assertNotIn("我是白花", result)
self.assertNotIn("话不算多", result)
self.assertNotIn("先理清顺序", result)
self.assertIn("直接说你想做什么就行", result)
if __name__ == "__main__":
unittest.main()
+361
View File
@@ -0,0 +1,361 @@
# -*- coding: utf-8 -*-
"""旧业务模块回归测试;网络、邮件、签到及生产文件路径全部隔离。"""
import asyncio
import queue
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
# The minimal audit container lacks aiohttp. Tests replace every network object,
# so a tiny import stub is sufficient there; production uses the real dependency.
try:
import aiohttp # noqa: F401
except ModuleNotFoundError:
aiohttp_stub = types.ModuleType("aiohttp")
class ClientTimeout:
def __init__(self, *args, **kwargs):
pass
aiohttp_stub.ClientTimeout = ClientTimeout
aiohttp_stub.ClientSession = None
sys.modules["aiohttp"] = aiohttp_stub
import for_admin_komari_agent as komari # noqa: E402
import ginkgostu # noqa: E402
import identity_cmd # noqa: E402
import jm # noqa: E402
import live_monitor # noqa: E402
import mail # noqa: E402
import migrate_chat_data # noqa: E402
import pixiv # noqa: E402
import setu # noqa: E402
class GinkgoRegressionTests(unittest.TestCase):
URL = "https://ginkgostu.dufe.edu.cn/course/detail/1K/batch/2/signin/9/2"
def test_retry_status_confirms_success_without_another_commit(self):
response = MagicMock()
response.json.return_value = {
"data": {"signInModel": {"type": 2, "title": "t"}, "signStatus": 1}
}
session = MagicMock()
with patch.object(ginkgostu, "call_api", return_value=response), \
patch.object(ginkgostu, "commit_sign_in") as commit:
result = ginkgostu.fetch_sign_in_info(
"u", "p", self.URL, sess=session, token="token",
avoid_duplicate=True)
self.assertTrue(result["commit"]["success"])
commit.assert_not_called()
session.close.assert_not_called() # 调用方所有的缓存会话不能关闭
def test_owned_signin_session_closes_on_query_failure(self):
session = MagicMock()
with patch.object(ginkgostu, "login", return_value=(session, "token", "n")), \
patch.object(ginkgostu, "call_api",
side_effect=ginkgostu.UpstreamError("bad response")):
with self.assertRaises(ginkgostu.UpstreamError):
ginkgostu.fetch_sign_in_info("u", "p", self.URL)
session.close.assert_called_once_with()
def test_login_failure_closes_sso_session(self):
session = MagicMock()
with patch.object(ginkgostu, "new_session", return_value=session), \
patch.object(ginkgostu, "get_unify_config",
side_effect=ginkgostu.UpstreamError("failed")):
with self.assertRaises(ginkgostu.UpstreamError):
ginkgostu.login("u", "p")
session.close.assert_called_once_with()
def test_refresh_closes_replaced_cached_session(self):
old = MagicMock()
new = MagicMock()
cached = ginkgostu.GinkgoSession("u", "p")
cached.req = old
with patch.object(ginkgostu, "login", return_value=(new, "new-token", "n")):
cached.refresh()
old.close.assert_called_once_with()
self.assertIs(cached.req, new)
def test_signin_api_forwards_retry_status_check(self):
with patch.object(ginkgostu, "_call_sign_in_local",
return_value=(True, "ok", {}, False)) as local:
ginkgostu.call_sign_in_api("u", "p", self.URL, check_status=True)
self.assertTrue(local.call_args.kwargs["check_status"])
class MailRegressionTests(unittest.TestCase):
def test_transport_result_unknown_is_not_a_plain_send_failure(self):
user = {"addresses": ["sender@rrrosmontis.icu"], "default": "",
"push": {"enabled": False, "last_seen": {}}}
with patch.object(mail, "_load_user", return_value=user), \
patch.object(mail, "_ComSession", side_effect=RuntimeError("lost response")):
result = asyncio.run(mail.send_with_attachments(
42, "sender@rrrosmontis.icu", "to@example.net", "s", "b", []))
self.assertFalse(result["ok"])
self.assertEqual(result["error_code"], "send_unknown")
def test_explicit_bridge_rejection_remains_send_failed(self):
user = {"addresses": ["sender@rrrosmontis.icu"], "default": "",
"push": {"enabled": False, "last_seen": {}}}
app = MagicMock()
app.post.return_value = {"accepted": False}
with patch.object(mail, "_load_user", return_value=user), \
patch.object(mail, "_ComSession") as session:
session.return_value.__enter__.return_value = app
result = asyncio.run(mail.send_with_attachments(
42, "sender@rrrosmontis.icu", "to@example.net", "s", "b", []))
self.assertEqual(result["error_code"], "send_failed")
class LiveMonitorRegressionTests(unittest.TestCase):
def setUp(self):
self.client = live_monitor.app.test_client()
live_monitor._live_sessions.clear()
self.records = {"follows": {"42": [{
"chat_type": "group", "chat_id": 7, "channel": "qq", "site": "1",
}]}}
self.secret = patch.object(live_monitor, "SRS_CALLBACK_SECRET", "test-secret")
self.load = patch.object(live_monitor, "_load_records", return_value=self.records)
self.notifications = patch.object(live_monitor, "notification_queue", queue.Queue())
self.secret.start()
self.load.start()
self.notifications.start()
def tearDown(self):
self.notifications.stop()
self.load.stop()
self.secret.stop()
live_monitor._live_sessions.clear()
def test_missing_srs_secret_fails_closed(self):
with patch.object(live_monitor, "SRS_CALLBACK_SECRET", ""):
response = self.client.post(
"/remote_srs/on_publish", json={"stream": "room_42"})
self.assertEqual(response.status_code, 403)
self.assertTrue(live_monitor.notification_queue.empty())
def test_srs_auth_dedupe_and_unpublish_rearm(self):
payload = {"stream": "room_42"}
denied = self.client.post("/remote_srs/on_publish", json=payload)
self.assertEqual(denied.status_code, 403)
self.assertTrue(live_monitor.notification_queue.empty())
for _ in range(2):
accepted = self.client.post(
"/remote_srs/on_publish?token=test-secret", json=payload)
self.assertEqual(accepted.status_code, 200)
self.assertEqual(live_monitor.notification_queue.qsize(), 1)
denied_end = self.client.post("/remote_srs/on_unpublish", json=payload)
self.assertEqual(denied_end.status_code, 403)
ended = self.client.post(
"/remote_srs/on_unpublish", json=payload,
headers={"X-SRS-Token": "test-secret"})
self.assertEqual(ended.status_code, 200)
self.client.post("/remote_srs/on_publish?token=test-secret", json=payload)
self.assertEqual(live_monitor.notification_queue.qsize(), 2)
class KomariAndIdentityTests(unittest.IsolatedAsyncioTestCase):
async def test_recent_exception_preserves_state_without_offline_notice(self):
komari._last_state = {"node-1": {"name": "one", "offline": False}}
session = MagicMock()
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=None)
async def get_json(_session, path, token=None):
if path == "/api/nodes":
return {"status": "success", "data": [{"uuid": "node-1", "name": "one"}]}
raise RuntimeError("temporary recent failure")
with patch.object(komari, "_login", AsyncMock(return_value="key")), \
patch.object(komari.aiohttp, "ClientSession", return_value=session), \
patch.object(komari, "_get_json", side_effect=get_json), \
patch.object(komari, "_notify_admins", AsyncMock()) as notify:
await komari._check_servers_once()
self.assertEqual(komari._last_state["node-1"]["offline"], False)
notify.assert_not_awaited()
async def test_notification_send_false_is_not_reported_as_sent(self):
channel = object()
with patch.object(identity_cmd, "get_channel", return_value=channel), \
patch.object(identity_cmd, "send_message", AsyncMock(return_value=False)):
self.assertFalse(await identity_cmd._notify("qq", 42, "text"))
with patch.object(identity_cmd, "get_channel", return_value=channel), \
patch.object(identity_cmd, "send_message", AsyncMock(return_value=True)):
self.assertTrue(await identity_cmd._notify("qq", 42, "text"))
class QuarantineRegressionTests(unittest.TestCase):
def test_same_name_quarantine_files_never_overwrite(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "source"
dest = root / "dest"
first = source / "a" / "same.txt"
second = source / "b" / "same.txt"
first.parent.mkdir(parents=True)
second.parent.mkdir(parents=True)
migrator = migrate_chat_data.ChatMigrator.__new__(
migrate_chat_data.ChatMigrator)
migrator.source = source
migrator.dest = dest
migrator.report = {"quarantine": []}
first.write_bytes(b"first")
second.write_bytes(b"second")
migrator._quarantine(first, "history", "one")
migrator._quarantine(second, "history", "two")
first.write_bytes(b"changed later")
migrator._quarantine(first, "history", "three")
saved = sorted(p.read_bytes() for p in (dest / "quarantine" / "history").iterdir())
self.assertEqual(saved, [b"changed later", b"first", b"second"])
class JmRegressionTests(unittest.TestCase):
def test_local_pack_import_and_stale_workdir_cleanup(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
album = "350234"
stale = root / album / "old-chapter" / "stale.jpg"
stale.parent.mkdir(parents=True)
stale.write_bytes(b"stale")
plan = ("title", [(101,)], 1, 1, "title.zip")
class Downloader:
def __init__(self, _option):
pass
def download_photo(self, photo_id):
target = root / album / str(photo_id)
target.mkdir(parents=True, exist_ok=True)
(target / "image.webp").write_bytes(b"not-an-image-is-copied")
class Options:
@staticmethod
def construct(_config):
return object()
class Config:
option_class = Options
@staticmethod
def downloader_class():
return Downloader
jmcomic_stub = types.ModuleType("jmcomic")
jmcomic_stub.JmModuleConfig = Config
with patch.object(jm, "JM_DIR", str(root)), \
patch.dict(sys.modules, {"jmcomic": jmcomic_stub}):
title, zip_path, count, ok, failed = jm._download_and_pack(
album, 1, 1, plan=plan)
self.assertEqual((title, count, ok, failed), ("title", 1, 1, 0))
self.assertTrue(zip_path.is_file())
self.assertFalse(stale.exists())
class ImageCleanupTests(unittest.IsolatedAsyncioTestCase):
async def test_pixiv_cancellation_cleans_cache_and_releases_lock(self):
with tempfile.TemporaryDirectory() as tmp:
pid = "424242"
local = Path(tmp) / (pid + ".jpg")
local.write_bytes(b"image")
lock = pixiv.get_pid_lock(pid)
with patch.object(pixiv, "SEND_IMAGE", 1), \
patch.object(pixiv, "RETAIN_MODE", "oss"), \
patch.object(pixiv, "IS_NAPCAT_SERVER_OSS_AVAILABLE", False), \
patch.object(pixiv, "OSS_SAVE_DIR", str(Path(tmp) / "oss")), \
patch.object(pixiv, "_load_proxy_ports", return_value=[]), \
patch.object(pixiv, "download_image", return_value=local), \
patch.object(pixiv, "image_base64", return_value="base64://x"), \
patch("channel.send_message", AsyncMock(return_value=True)), \
patch.object(pixiv.asyncio, "sleep",
AsyncMock(side_effect=asyncio.CancelledError)):
with self.assertRaises(asyncio.CancelledError):
await pixiv.handle_pixiv(MagicMock(name="qq"), "private", 1, 2,
".pixiv " + pid)
self.assertFalse(local.exists())
self.assertFalse(lock.locked())
async def test_setu_cancellation_cleans_cache_and_releases_lock(self):
class Response:
status = 200
async def json(self):
return {"data": [{"pid": 77, "urls": {
"original": "https://i.pixiv.re/img/77.jpg"}}]}
class Context:
async def __aenter__(self):
return Response()
async def __aexit__(self, *args):
return None
class Session:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
def get(self, *args, **kwargs):
return Context()
with tempfile.TemporaryDirectory() as tmp:
local = Path(tmp) / "77.jpg"
local.write_bytes(b"image")
lock = pixiv.get_pid_lock(77)
with patch.object(setu, "SEND_IMAGE", 1), \
patch.object(setu, "RETAIN_MODE", "oss"), \
patch.object(setu, "IS_NAPCAT_SERVER_OSS_AVAILABLE", False), \
patch.object(setu, "OSS_SAVE_DIR", str(Path(tmp) / "oss")), \
patch.object(setu, "_load_proxy_ports", return_value=[]), \
patch.object(setu, "count_oss_files", return_value=0), \
patch.object(setu.aiohttp, "ClientSession", Session), \
patch.object(pixiv, "download_image", return_value=local), \
patch.object(pixiv, "image_base64", return_value="base64://x"), \
patch("channel.send_message", AsyncMock(return_value=True)), \
patch.object(setu.asyncio, "sleep",
AsyncMock(side_effect=asyncio.CancelledError)):
with self.assertRaises(asyncio.CancelledError):
await setu.handle_setu([], None, None, "private", 1, MagicMock(name="qq"))
self.assertFalse(local.exists())
self.assertFalse(lock.locked())
async def test_interrupted_download_removes_partial_file(self):
class Response:
status_code = 200
headers = {"Content-Type": "image/jpeg"}
def iter_content(self, _size):
yield b"partial"
raise RuntimeError("connection lost")
def close(self):
self.closed = True
response = Response()
with tempfile.TemporaryDirectory() as tmp, \
patch.object(pixiv, "PIXIV_CACHE_DIR", tmp), \
patch("requests.get", return_value=response):
with self.assertRaises(RuntimeError):
pixiv.download_image("88")
self.assertEqual(list(Path(tmp).iterdir()), [])
self.assertTrue(response.closed)
if __name__ == "__main__":
unittest.main()
+369
View File
@@ -0,0 +1,369 @@
# -*- coding: utf-8 -*-
"""附件邮件协议、校验与 MIME 测试;全部外部邮件/COM 操作均 mock。"""
import asyncio
import base64
import email
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from flask import Flask
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE / "mail-bridge"), str(BASE)]
import mail # noqa: E402
# com_backend 在 import 时读取配置;这里只提供无真实凭据的测试配置。
_CONFIG_TMP = tempfile.TemporaryDirectory(prefix="mail-attachment-test-config-")
_CONFIG_PATH = Path(_CONFIG_TMP.name) / "com-private.json"
_CONFIG_PATH.write_text(json.dumps({
"hmail_admin_password": "not-used-by-mock-tests",
"token": "t" * 128,
"allowed_domains": ["rrrosmontis.icu", "aeolianchi.me"],
}), encoding="utf-8")
with patch.dict(os.environ, {"MAIL_COM_CONFIG": str(_CONFIG_PATH)}):
import com_backend as backend # noqa: E402
import admin_com # noqa: E402
SENDER = "sender@rrrosmontis.icu"
RECIPIENT = "receiver@example.net"
AUTH = {"Authorization": "Bearer " + "t" * 128}
ENDPOINT = "/v1/mail/api/admin/com/send_with_attachments"
SEND_ENDPOINT = "/v1/mail/api/admin/com/send"
def outgoing_payload(**overrides):
data = {
"from_address": SENDER,
"to_address": RECIPIENT,
"subject": "附件测试",
"body_text": "正文",
"attachments": [],
}
data.update(overrides)
return data
def wire_attachment(content=b"hello", filename="测试.txt",
content_type="text/plain"):
return {
"filename": filename,
"content_type": content_type,
"data_base64": base64.b64encode(content).decode("ascii"),
}
class ClientAttachmentTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="mail-attachment-client-")
self.mail_dir = Path(self.tmp.name) / "mail"
self.mail_dir.mkdir()
self.dir_patch = patch.object(mail, "MAIL_DIR", str(self.mail_dir))
self.dir_patch.start()
mail._save_user(42, {
"addresses": [SENDER],
"default": SENDER,
"push": {"enabled": False, "interval_minutes": 5, "last_seen": {}},
})
self.files = Path(self.tmp.name) / "files"
self.files.mkdir()
def tearDown(self):
self.dir_patch.stop()
self.tmp.cleanup()
def send(self, paths, **kwargs):
args = dict(user_id=42, from_address=SENDER, to_address=RECIPIENT,
subject="主题", body_text="正文", attachment_paths=paths)
args.update(kwargs)
return asyncio.run(mail.send_with_attachments(**args))
def test_success_uses_base64_protocol_and_chinese_filename(self):
path = self.files / "中文 报告.txt"
content = "附件内容".encode("utf-8")
path.write_bytes(content)
app = MagicMock()
app.post.return_value = {"ok": True, "accepted": True, "sent_saved": True}
with patch.object(mail, "_ComSession") as session:
session.return_value.__enter__.return_value = app
result = self.send([path])
self.assertTrue(result["ok"])
self.assertEqual(result["from"], SENDER)
self.assertEqual(result["to"], RECIPIENT)
self.assertEqual(result["attachment_names"], ["中文 报告.txt"])
self.assertEqual(result["attachment_count"], 1)
self.assertEqual(result["size"], len(content))
self.assertTrue(result["sent_saved"])
endpoint, payload = app.post.call_args.args
self.assertEqual(endpoint, "/send_with_attachments")
self.assertNotIn("path", payload["attachments"][0])
self.assertEqual(payload["attachments"][0]["filename"], "中文 报告.txt")
self.assertEqual(base64.b64decode(
payload["attachments"][0]["data_base64"], validate=True), content)
self.assertEqual(payload["attachments"][0]["content_type"], "text/plain")
def test_from_address_must_be_bound(self):
with patch.object(mail, "_ComSession") as session:
result = self.send([], from_address="other@rrrosmontis.icu")
self.assertFalse(result["ok"])
self.assertEqual(result["error_code"], "from_not_bound")
session.assert_not_called()
def test_recipient_subject_and_body_validation(self):
cases = [
({"to_address": "not-an-address"}, "invalid_recipient"),
({"subject": "bad\nsubject"}, "invalid_subject"),
({"subject": " "}, "invalid_subject"),
({"body_text": None}, "invalid_body"),
]
for overrides, code in cases:
with self.subTest(code=code):
with patch.object(mail, "_ComSession") as session:
result = self.send([], **overrides)
self.assertFalse(result["ok"])
self.assertEqual(result["error_code"], code)
session.assert_not_called()
def test_single_file_total_and_count_limits(self):
one = self.files / "one.bin"
one.write_bytes(b"12345")
with patch.object(mail, "MAX_ATTACHMENT_BYTES", 4):
result = self.send([one])
self.assertEqual(result["error_code"], "file_too_large")
two = self.files / "two.bin"
one.write_bytes(b"123")
two.write_bytes(b"456")
with patch.object(mail, "MAX_ATTACHMENT_BYTES", 4), \
patch.object(mail, "MAX_ATTACHMENT_TOTAL_BYTES", 5):
result = self.send([one, two])
self.assertEqual(result["error_code"], "total_too_large")
with patch.object(mail, "MAX_ATTACHMENT_COUNT", 1):
result = self.send([one, two])
self.assertEqual(result["error_code"], "too_many_attachments")
def test_directory_and_special_file_are_rejected(self):
result = self.send([self.files])
self.assertFalse(result["ok"])
self.assertEqual(result["error_code"], "not_regular_file")
if Path("/dev/null").exists():
result = self.send(["/dev/null"])
self.assertEqual(result["error_code"], "not_regular_file")
def test_transport_failure_is_safe_and_structured(self):
secret_path = self.files / "private-name.txt"
secret_path.write_bytes(b"x")
with patch.object(mail, "_ComSession", side_effect=RuntimeError(
"token and /private/path must not leak")):
result = self.send([secret_path])
self.assertFalse(result["ok"])
self.assertEqual(result["error_code"], "send_unknown")
self.assertNotIn("private", result["error"])
self.assertNotIn("token", result["error"])
def test_legacy_client_send_payload_is_unchanged(self):
app = MagicMock()
mail._send_mail(app, SENDER, RECIPIENT, "旧主题", "旧正文")
app.post.assert_called_once_with("/send", {
"from_address": SENDER,
"to_address": RECIPIENT,
"subject": "旧主题",
"body_text": "旧正文",
})
class BridgeAttachmentTests(unittest.TestCase):
def setUp(self):
self.flask = Flask(__name__)
self.flask.config.update(TESTING=True)
admin_com.install(self.flask)
self.client = self.flask.test_client()
self.app = MagicMock()
self.session_patch = patch.object(backend, "_ComSession")
session = self.session_patch.start()
session.return_value.__enter__.return_value = self.app
self.account_patch = patch.object(backend, "_get_account")
self.account_patch.start()
def tearDown(self):
self.account_patch.stop()
self.session_patch.stop()
def post(self, payload, endpoint=ENDPOINT):
return self.client.post(endpoint, json=payload, headers=AUTH)
def test_new_endpoint_decodes_attachments_and_preserves_chinese_name(self):
content = "中文附件".encode("utf-8")
with patch.object(backend, "_send_mail_with_attachments", return_value=True) as send:
response = self.post(outgoing_payload(
attachments=[wire_attachment(content=content)]))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json(), {
"ok": True,
"accepted": True,
"sent_saved": True,
"attachment_names": ["测试.txt"],
"size": len(content),
})
args = send.call_args.args
self.assertEqual(args[1:5], (SENDER, RECIPIENT, "附件测试", "正文"))
self.assertEqual(args[5][0]["filename"], "测试.txt")
self.assertEqual(args[5][0]["content"], content)
def test_new_endpoint_rejects_paths_bad_mime_and_bad_base64(self):
bad_attachments = [
dict(wire_attachment(), filename="../secret.txt"),
dict(wire_attachment(), filename="C:\\secret.txt"),
dict(wire_attachment(), content_type="text/plain\r\nX-Evil: yes"),
dict(wire_attachment(), content_type="text/plain; charset=utf-8"),
dict(wire_attachment(), data_base64="not base64!"),
dict(wire_attachment(), path="C:/secret.txt"),
]
with patch.object(backend, "_send_mail_with_attachments") as send:
for attachment in bad_attachments:
with self.subTest(attachment=attachment):
response = self.post(outgoing_payload(attachments=[attachment]))
self.assertEqual(response.status_code, 400)
self.assertEqual(response.get_json(), {"error": "invalid request"})
send.assert_not_called()
def test_new_endpoint_rejects_empty_attachment_list_without_sending(self):
with patch.object(backend, "_send_mail_with_attachments") as send:
response = self.post(outgoing_payload(attachments=[]))
self.assertEqual(response.status_code, 400)
send.assert_not_called()
def test_new_endpoint_revalidates_count_file_and_total_size(self):
with patch.object(admin_com, "MAX_ATTACHMENT_COUNT", 1), \
patch.object(backend, "_send_mail_with_attachments") as send:
response = self.post(outgoing_payload(attachments=[
wire_attachment(filename="a.txt"), wire_attachment(filename="b.txt")]))
self.assertEqual(response.status_code, 400)
send.assert_not_called()
with patch.object(admin_com, "MAX_ATTACHMENT_BYTES", 4), \
patch.object(backend, "_send_mail_with_attachments") as send:
response = self.post(outgoing_payload(
attachments=[wire_attachment(content=b"12345")]))
self.assertEqual(response.status_code, 400)
send.assert_not_called()
with patch.object(admin_com, "MAX_ATTACHMENT_BYTES", 4), \
patch.object(admin_com, "MAX_ATTACHMENT_TOTAL_BYTES", 5), \
patch.object(backend, "_send_mail_with_attachments") as send:
response = self.post(outgoing_payload(attachments=[
wire_attachment(content=b"123", filename="a.txt"),
wire_attachment(content=b"456", filename="b.txt")]))
self.assertEqual(response.status_code, 400)
send.assert_not_called()
def test_request_body_limits_are_endpoint_specific(self):
# 大于旧端点 1 MiB、小于附件端点默认 40 MiB 的合法请求可通过入口限制。
large_content = b"x" * (800 * 1024)
with patch.object(backend, "_send_mail_with_attachments", return_value=True):
response = self.post(outgoing_payload(
attachments=[wire_attachment(content=large_content)]))
self.assertEqual(response.status_code, 200)
response = self.client.post(
SEND_ENDPOINT, data=b"x" * (1024 * 1024 + 1),
content_type="application/json", headers=AUTH)
self.assertEqual(response.status_code, 413)
# 用小上限 mock 大请求,验证附件端点自身也有独立硬限制。
with patch.object(admin_com, "ATTACHMENT_REQUEST_MAX_BYTES", 1024):
response = self.client.post(
ENDPOINT, data=b"x" * 1025,
content_type="application/json", headers=AUTH)
self.assertEqual(response.status_code, 413)
def test_legacy_send_endpoint_contract_is_unchanged(self):
payload = outgoing_payload()
payload.pop("attachments")
with patch.object(backend, "_send_mail") as old_send, \
patch.object(backend, "_send_mail_with_attachments") as new_send:
response = self.post(payload, endpoint=SEND_ENDPOINT)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json(), {"accepted": True})
old_send.assert_called_once_with(
self.app, SENDER, RECIPIENT, "附件测试", "正文")
new_send.assert_not_called()
class BackendMimeTests(unittest.TestCase):
def test_mime_contains_utf8_body_and_rfc2231_chinese_filename(self):
content = "二进制\x00内容".encode("utf-8")
msg = backend._build_attachment_mime(
SENDER, RECIPIENT, "中文主题", "纯文本正文",
[{"filename": "中文 报告.txt", "content_type": "text/plain",
"content": content}])
parsed = email.message_from_bytes(msg.as_bytes())
self.assertEqual(parsed.get_content_type(), "multipart/mixed")
self.assertEqual(parsed["To"], RECIPIENT)
attachment = None
plain = None
for part in parsed.walk():
if part.get_content_disposition() == "attachment":
attachment = part
elif part.get_content_type() == "text/plain":
plain = part
self.assertIsNotNone(attachment)
self.assertEqual(attachment.get_filename(), "中文 报告.txt")
self.assertEqual(attachment.get_payload(decode=True), content)
self.assertEqual(plain.get_payload(decode=True).decode("utf-8"), "纯文本正文")
def test_smtp_delivery_is_mocked_and_uses_raw_mime(self):
smtp = MagicMock()
smtp.sendmail.return_value = {}
manager = MagicMock()
manager.__enter__.return_value = smtp
with patch.object(backend.smtplib, "SMTP", return_value=manager) as factory, \
patch.dict(backend.CONFIG, {"smtp_host": "127.0.0.1", "smtp_port": 25,
"smtp_ssl": False, "smtp_starttls": False,
"smtp_username": ""}, clear=False):
backend._deliver_mime(SENDER, RECIPIENT, b"MIME-BYTES")
factory.assert_called_once_with("127.0.0.1", 25, timeout=30.0)
smtp.sendmail.assert_called_once_with(SENDER, [RECIPIENT], b"MIME-BYTES")
def test_no_attachments_reuses_legacy_send_and_sent(self):
app = MagicMock()
with patch.object(backend, "_send_mail") as old_send, \
patch.object(backend, "_save_to_sent") as old_sent, \
patch.object(backend, "_deliver_mime") as smtp:
result = backend._send_mail_with_attachments(
app, SENDER, RECIPIENT, "主题", "正文", [])
self.assertIsNone(result)
old_send.assert_called_once_with(app, SENDER, RECIPIENT, "主题", "正文")
old_sent.assert_called_once_with(app, SENDER, RECIPIENT, "主题", "正文")
smtp.assert_not_called()
def test_sent_copy_uses_documented_com_attachment_add(self):
message = MagicMock()
added = []
def add_attachment(path):
added.append((os.path.basename(path), Path(path).read_bytes()))
message.Attachments.Add.side_effect = add_attachment
account = MagicMock()
account.IMAPFolders.ItemByName.return_value.Messages.Add.return_value = message
with patch.object(backend, "_get_account", return_value=account):
saved = backend._save_attachment_copy_to_sent(
MagicMock(), SENDER, RECIPIENT, "主题", "正文",
[{"filename": "中文.txt", "content_type": "text/plain",
"content": b"content"}])
self.assertTrue(saved)
self.assertEqual(added, [("中文.txt", b"content")])
message.AddRecipient.assert_called_once_with("", RECIPIENT)
message.Save.assert_called_once_with()
if __name__ == "__main__":
unittest.main()
+32
View File
@@ -0,0 +1,32 @@
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import mail
class MailReferenceTest(unittest.TestCase):
def test_latest_event_reference_and_explicit_id(self):
with tempfile.TemporaryDirectory() as tmp:
history = Path(tmp) / "chat" / "history"
history.mkdir(parents=True)
(history / "1.txt").write_text(
"[event:mail:2644] old\n[event:mail:2645] newest\n", encoding="utf-8")
with patch.object(mail, "BASE_DIR", tmp):
self.assertEqual(
mail.resolve_context_reference(1, "帮我查看一下这篇邮件说了什么"),
"帮我查看一下编号为 2645 的邮件说了什么")
self.assertEqual(
mail.resolve_context_reference(1, "查看编号2644的邮件"),
"查看编号2644的邮件")
def test_missing_history_is_not_guessed(self):
with tempfile.TemporaryDirectory() as tmp, patch.object(mail, "BASE_DIR", tmp):
text = "帮我查看一下这篇邮件说了什么"
self.assertEqual(mail.resolve_context_reference(1, text), text)
if __name__ == "__main__":
unittest.main()
+74
View File
@@ -0,0 +1,74 @@
import os,sys,tempfile,json,unittest
from pathlib import Path
from unittest.mock import patch,MagicMock
BASE=Path(__file__).resolve().parents[1]
sys.path[:0]=[str(BASE/'bot-utility'),str(BASE)]
import mail
from tg_channel import TGChannel
class MigrationTests(unittest.TestCase):
def test_mail_session_requires_tls(self):
with patch.dict(os.environ,{'MAIL_COM_URL':'http://remote.example/admin/com'}):
with self.assertRaises(RuntimeError):
with mail._ComSession():pass
def test_mail_client_request_and_close(self):
with tempfile.TemporaryDirectory() as d:
p=Path(d)/'token';p.write_text('t'*128)
with patch.dict(os.environ,{'MAIL_COM_TOKEN_FILE':str(p)}),patch('requests.Session') as factory:
factory.return_value.post.return_value.json.return_value={'items':[{'id':42}]}
with mail._ComSession() as session:
result=mail._list_inbox_messages(session,'a@rrrosmontis.icu',limit=5)
self.assertEqual(result,[{'id':42}])
self.assertFalse(factory.return_value.trust_env)
self.assertEqual(factory.return_value.post.call_args.kwargs['json']['limit'],5)
factory.return_value.close.assert_called_once()
def test_body_attachment_metadata_does_not_open_local_eml(self):
app=MagicMock()
app.post.return_value={'message':{'ID':8,'Subject':'s','From':'a@b.c','Date':'d','Body':'text','HTMLBody':'<p>body</p><script>bad()</script>','image_count':2,'attachment_names':['a.pdf']}}
m=mail._find_message_by_id(app,['a@rrrosmontis.icu'],8)
self.assertEqual(mail._analyze_message(m),(2,['a.pdf']))
self.assertEqual(mail._extract_body(m),'body')
def test_initial_poll_keeps_com_id_and_does_not_push_history(self):
with tempfile.TemporaryDirectory() as d,patch.object(mail,'MAIL_DIR',d),patch.object(mail,'_ComSession'),patch.object(mail,'_mail_changes',return_value={'max_id':2386,'items':[]}):
(Path(d)/'9000000001.json').write_text(json.dumps({'addresses':['a@rrrosmontis.icu'],'push':{'enabled':True,'last_seen':{}}}))
mail._poll_next.clear()
with patch.object(mail.notification_queue,'put') as put:
mail._poll_once();put.assert_not_called()
self.assertEqual(mail._load_user(9000000001)['push']['last_seen']['a@rrrosmontis.icu'],2386)
def test_incremental_poll_queues_without_advancing_watermark(self):
addr='a@rrrosmontis.icu'
with tempfile.TemporaryDirectory() as d,patch.object(mail,'MAIL_DIR',d),patch.object(mail,'_ComSession'),patch.object(mail.identity,'push_targets',return_value=[('tg',1)]):
(Path(d)/'9000000001.json').write_text(json.dumps({'addresses':[addr],'push':{'enabled':True,'last_seen':{addr:10}}}))
mail._poll_next.clear();mail._inflight.clear()
changes={'max_id':99,'items':[{'id':12,'subject':'s','from':'a','from_address':'a@b.c','ts':1},{'id':14,'subject':'s','from':'a','from_address':'a@b.c','ts':1}]}
with patch.object(mail,'_mail_changes',return_value=changes),patch.object(mail.notification_queue,'put') as put:
mail._poll_once()
self.assertEqual([c.args[0]['mid'] for c in put.call_args_list],[12,14])
self.assertEqual(mail._load_user(9000000001)['push']['last_seen'][addr],10)
mail._inflight.clear()
def test_oss_uris_and_traversal(self):
self.assertEqual(TGChannel._translate_oss_path('file:///C:/oss/pixiv/a%20b.jpg'),'/mnt/oss/pixiv/a b.jpg')
self.assertEqual(TGChannel._translate_oss_path('file:///mnt/A/pixiv/a.jpg'),'/mnt/oss/pixiv/a.jpg')
with self.assertRaises(ValueError):TGChannel._translate_oss_path('file:///mnt/A/../secret')
def test_no_windows_admin_secret_or_windows_paths(self):
self.assertIsNone(mail.HMAIL_ADMIN_PASSWORD)
for name in ['jm','pixiv','setu']:
module=__import__(name)
# Runtime proxy config is private and intentionally absent in a clone.
configured = os.environ.get('BOT_PROXY_CONFIG', str(BASE/'bot-utility'/'proxies.json'))
self.assertEqual(Path(module.PROXY_CONFIG_PATH), Path(configured))
self.assertNotIn('C:',module.PROXY_CONFIG_PATH)
import pixiv,setu,for_admin_komari_agent
self.assertEqual(pixiv.OSS_SAVE_DIR,setu.OSS_SAVE_DIR)
self.assertEqual(for_admin_komari_agent.KOMARI_API_BASE,'https://status.rrrosmontis.icu')
if __name__=='__main__':unittest.main()
+28
View File
@@ -0,0 +1,28 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import mock_open, patch
import identity
from agent.modes import ModeStore
class ModeConfigTest(unittest.TestCase):
def test_admin_chat_other_command_and_saved_override(self):
with tempfile.TemporaryDirectory() as tmp:
store = ModeStore(Path(tmp) / "modes.db")
config = json.dumps({"chat_mode": {"default": "command", "admin": "chat", "group": "command"}})
with patch("pathlib.Path.open", mock_open(read_data=config)), \
patch.object(identity, "is_admin", side_effect=lambda uid: uid == 1):
self.assertEqual(store.get_mode("tg", 1), "chat")
self.assertEqual(store.get_mode("tg", 2), "command")
self.assertEqual(store.get_mode("tg", 1, 123), "command")
store.set_mode("tg", 2, "chat")
self.assertEqual(store.get_mode("tg", 2), "chat")
store.set_mode("tg", 1, "command")
self.assertEqual(store.get_mode("tg", 1), "command")
if __name__ == "__main__":
unittest.main()
+114
View File
@@ -0,0 +1,114 @@
"""Native deepseek-flash multimodal chat tests; no real API calls."""
import base64
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import AsyncMock, patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
import agent_loop
from agent import ToolResult
from chat import engine
class MultimodalContentTests(unittest.IsolatedAsyncioTestCase):
async def test_local_image_and_task_share_one_user_content(self):
payload = b"\x89PNG\r\n\x1a\nsmall-test-image"
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "task.png"
path.write_bytes(payload)
content = await engine._build_user_content(
"总结图片,并读出其中要做的任务",
[{"url": str(path), "file_size": str(len(payload))}],
)
self.assertIsInstance(content, list)
self.assertEqual(content[0]["type"], "image_url")
self.assertEqual(content[1]["type"], "text")
self.assertEqual(content[1]["text"], "总结图片,并读出其中要做的任务")
data_url = content[0]["image_url"]["url"]
self.assertTrue(data_url.startswith("data:image/png;base64,"))
self.assertEqual(base64.b64decode(data_url.split(",", 1)[1]), payload)
async def test_text_only_content_is_unchanged(self):
content = await engine._build_user_content("普通聊天", None)
self.assertEqual(content, "普通聊天")
async def test_main_call_passes_same_multimodal_content_to_official_model(self):
parts = [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,eA=="}},
{"type": "text", "text": "读图"},
]
official = AsyncMock(return_value="ok")
with patch.object(engine, "_modelscope_available", return_value=False), \
patch.object(engine, "_build_user_content", new=AsyncMock(return_value=parts)), \
patch.object(engine, "_call_deepseek_official", new=official):
result = await engine.call_deepseek("system", "读图", image_infos=[{"url": "x"}])
self.assertEqual(result, "ok")
self.assertIs(official.await_args.args[1], parts)
self.assertEqual(engine.MODEL_NAME, "deepseek-flash")
async def test_private_pending_flow_forwards_original_images_without_description_step(self):
images = [{"url": "/tmp/original.png"}]
reply = AsyncMock()
engine._user_pending_messages[123] = ["按图执行"]
engine._user_pending_images[123] = images
try:
with patch.object(engine, "_call_model_and_reply", new=reply):
await engine._process_pending_messages(123, 0)
finally:
engine._user_pending_messages.pop(123, None)
engine._user_pending_images.pop(123, None)
engine._user_pending_tasks.pop(123, None)
self.assertEqual(reply.await_args.args[2], "按图执行")
self.assertIs(reply.await_args.kwargs["image_infos"], images)
class FakeStore:
def start(self, uid, conversation):
return "task"
def record(self, *args):
pass
def finish(self, *args):
pass
class FakeExecutor:
async def execute(self, name, args, ctx):
return ToolResult(True, "ok", "done")
class MultiRoundEngine:
def __init__(self):
self.image_kwargs = []
self.reasoning_kwargs = []
async def call_deepseek(self, *args, **kwargs):
self.image_kwargs.append(kwargs.get("image_infos"))
self.reasoning_kwargs.append(kwargs.get("reasoning"))
return {"message": [{"content": "完成"}], "tools": []}
def parse_json_response(self, raw):
return raw
class AgentMultimodalTests(unittest.IsolatedAsyncioTestCase):
async def test_tool_followup_keeps_original_images(self):
images = [{"url": "/tmp/original.png"}]
model = MultiRoundEngine()
initial = {"message": [], "tools": [{"name": "mail", "args": {"action": "list"}}]}
channel = type("Channel", (), {"name": "tg", "is_admin": lambda self, uid: True})()
with patch.object(agent_loop, "AgentTaskStore", FakeStore), \
patch.object(agent_loop, "ToolExecutor", FakeExecutor):
result = await agent_loop.run_agent_loop(
model, initial, "system", "按图执行", channel, "private", 1, 2,
image_infos=images,
)
self.assertEqual(result["message"][0]["content"], "完成")
self.assertEqual(model.image_kwargs, [images])
self.assertEqual(model.reasoning_kwargs, [True])
if __name__ == "__main__":
unittest.main()
+26
View File
@@ -0,0 +1,26 @@
"""Avoid formulaic user-intent paraphrase openings without removing address preferences."""
from pathlib import Path
import sys
import unittest
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / 'bot-utility'), str(BASE)]
from chat import engine
class NoParaphraseOpenerTests(unittest.TestCase):
def test_global_style_covers_template_and_same_family(self):
text = engine.load_instruction('style_instructions.txt')
for phrase in ('主人这是在……呀', '原来主人是在', '看来主人是想',
'主人这是……呢', '你这是/看来你是/所以你是',
'无需每次先概括、解释或揣测', '不把猜测说成事实',
'“主人”这个称呼保留并自然使用'):
self.assertIn(phrase, text)
def test_live_and_builtin_defaults_match(self):
text = engine.load_instruction('style_instructions.txt')
self.assertEqual(text.strip(), engine.DEFAULT_STYLE_INSTRUCTIONS.strip())
self.assertIn('主人这是在……呀', engine.BASE_PROMPT)
if __name__ == '__main__':
unittest.main()
+296
View File
@@ -0,0 +1,296 @@
# -*- coding: utf-8 -*-
import asyncio
import os
import sqlite3
import sys
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
BACKEND = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BACKEND / "bot-utility"), str(BACKEND)]
import notebook as notebook_module
from notebook import HELP_TEXT, Notebook, SHANGHAI, format_memo, format_todo
NOW = datetime(2030, 1, 2, 12, 0, 0, tzinfo=SHANGHAI).timestamp()
HOUR = 3600
DAY = 24 * HOUR
def private_ctx(uid=1, channel="telegram"):
return SimpleNamespace(user_id=uid, channel=channel, msg_type="private", target_id=uid)
def group_ctx(target_id=100, channel="telegram", msg_type="group", uid=1):
return SimpleNamespace(
user_id=uid,
channel=SimpleNamespace(name=channel),
msg_type=msg_type,
target_id=target_id,
)
def iso_at(epoch):
return datetime.fromtimestamp(epoch, SHANGHAI).isoformat()
class NotebookTests(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tempdir.name, "notebook-test.db")
self.nb = Notebook(self.db_path)
self.ctx = private_ctx(1)
def tearDown(self):
self.nb.close()
self.tempdir.cleanup()
def add_todo(self, content, due, expires=None, created_at=NOW, ctx=None):
with mock.patch("notebook.time.time", return_value=created_at):
record = self.nb.add_todo(
ctx or self.ctx,
content,
iso_at(due),
None if expires is None else iso_at(expires),
)
self.assertEqual(record["content"], content)
self.assertEqual(record["status"], "active")
return record["id"]
def test_memo_add_query_delete_and_content_limit(self):
with mock.patch("notebook.time.time", side_effect=[NOW, NOW + 1]):
first_record = self.nb.add_memo(self.ctx, "购买牛奶")
second_record = self.nb.add_memo(self.ctx, "购买咖啡豆")
first = first_record["id"]
second = second_record["id"]
self.assertEqual(first_record["content"], "购买牛奶")
self.assertEqual(first_record["created_at"], NOW)
self.assertRegex(first, r"^[0-9a-f]{8}$")
self.assertNotEqual(first, second)
self.assertEqual([m["id"] for m in self.nb.list_memos(self.ctx)], [second, first])
self.assertEqual(
[m["id"] for m in self.nb.list_memos(self.ctx, "牛奶")], [first]
)
self.assertEqual(self.nb.list_memos(self.ctx, limit=0), [])
with self.assertRaises(ValueError):
self.nb.add_memo(self.ctx, "x" * 2001)
self.assertEqual(self.nb.delete_memo(self.ctx, first[:4]), first_record)
with self.assertRaises(ValueError):
self.nb.delete_memo(self.ctx, first)
self.assertEqual([m["id"] for m in self.nb.list_memos(self.ctx)], [second])
with sqlite3.connect(self.db_path) as con:
self.assertEqual(
con.execute("SELECT COUNT(*) FROM memo_notes WHERE id=?", (first,)).fetchone()[0],
0,
)
def test_memo_permissions_and_scope_isolation(self):
private_id = self.nb.add_memo(private_ctx(1, channel="one"), "私聊记录")["id"]
# 私聊只按 user_id 隔离,channel/target_id 不参与私聊作用域。
self.assertEqual(
self.nb.list_memos(private_ctx(1, channel="two"))[0]["id"], private_id
)
with self.assertRaises(ValueError):
self.nb.delete_memo(private_ctx(2), private_id)
group_one = group_ctx(target_id=100, uid=1)
same_group_other_sender = group_ctx(target_id=100, uid=999)
group_two = group_ctx(target_id=200, uid=1)
group_record = self.nb.add_memo(group_one, "群记录")
group_id = group_record["id"]
self.assertEqual(self.nb.list_memos(same_group_other_sender)[0]["id"], group_id)
self.assertEqual(self.nb.list_memos(group_two), [])
with self.assertRaises(ValueError):
self.nb.delete_memo(group_two, group_id)
with self.assertRaises(ValueError):
self.nb.delete_memo(group_ctx(channel="other"), group_id)
self.assertEqual(
self.nb.delete_memo(same_group_other_sender, group_id), group_record
)
def test_id_prefix_must_be_unique_within_scope(self):
with mock.patch(
"notebook.secrets.token_hex", side_effect=["aaaaaaa1", "aaaaaaa2"]
):
first = self.nb.add_memo(self.ctx, "一")
self.nb.add_memo(self.ctx, "二")
with self.assertRaisesRegex(ValueError, "不唯一"):
self.nb.delete_memo(self.ctx, "aaaa")
# 其他会话中相同前缀不应影响本会话解析。
other = private_ctx(2)
with self.assertRaises(ValueError):
self.nb.delete_memo(other, "aaaa")
self.assertEqual(self.nb.delete_memo(self.ctx, first["id"]), first)
def test_todo_due_default_expiry_and_validation(self):
due = NOW + 2 * HOUR
item_id = self.add_todo("提交报告", due)
item = self.nb.list_todos(self.ctx)[0]
self.assertEqual(item["id"], item_id)
self.assertEqual(item["due_at"], due)
self.assertEqual(item["expires_at"], due + DAY)
self.assertEqual(item["status"], "active")
self.assertIsNone(item["completed_at"])
explicit_id = self.add_todo("显式过期", NOW + 3 * HOUR, NOW + 4 * HOUR)
explicit = next(t for t in self.nb.list_todos(self.ctx) if t["id"] == explicit_id)
self.assertEqual(explicit["expires_at"], NOW + 4 * HOUR)
with self.assertRaisesRegex(ValueError, "未来"):
self.add_todo("过去", NOW - 1)
with self.assertRaisesRegex(ValueError, "晚于"):
self.add_todo("过期等于到期", NOW + HOUR, NOW + HOUR)
with self.assertRaises(ValueError):
self.nb.add_todo(self.ctx, "无效时间", "not-a-time")
with self.assertRaises(ValueError):
self.add_todo("x" * 2001, NOW + HOUR)
def test_complete_delete_and_permissions(self):
item_id = self.add_todo("待办权限", NOW + HOUR)
stranger = private_ctx(2)
with self.assertRaises(ValueError):
self.nb.complete_todo(stranger, item_id)
with self.assertRaises(ValueError):
self.nb.delete_todo(stranger, item_id)
self.assertEqual(self.nb.list_todos(self.ctx)[0]["status"], "active")
with mock.patch("notebook.time.time", return_value=NOW + 10):
completed_record = self.nb.complete_todo(self.ctx, item_id[:5])
self.assertEqual(completed_record["status"], "completed")
with self.assertRaises(ValueError):
self.nb.complete_todo(self.ctx, item_id)
self.assertEqual(self.nb.list_todos(self.ctx), [])
completed = self.nb.list_todos(self.ctx, include_completed=True)[0]
self.assertEqual(completed["status"], "completed")
self.assertEqual(completed["completed_at"], NOW + 10)
self.assertEqual(self.nb.delete_todo(self.ctx, item_id)["id"], item_id)
self.assertEqual(self.nb.list_todos(self.ctx, include_completed=True), [])
with sqlite3.connect(self.db_path) as con:
self.assertEqual(con.execute("SELECT COUNT(*) FROM todo_items").fetchone()[0], 0)
def test_cleanup_physically_deletes_only_expired_active(self):
expired_id = self.add_todo("过期未完成", NOW + HOUR) # expires = NOW + 25h
completed_id = self.add_todo("过期已完成", NOW + HOUR)
active_id = self.add_todo("仍有效", NOW + 48 * HOUR)
self.assertTrue(self.nb.complete_todo(self.ctx, completed_id))
# 等于 expires_at 即过期;completed 即使过期也保留。
self.assertEqual(self.nb.cleanup_expired(NOW + 25 * HOUR), 1)
with sqlite3.connect(self.db_path) as con:
rows = dict(con.execute("SELECT id,status FROM todo_items").fetchall())
self.assertNotIn(expired_id, rows)
self.assertEqual(rows[completed_id], "completed")
self.assertEqual(rows[active_id], "active")
self.assertEqual(self.nb.cleanup_expired(NOW + 25 * HOUR), 0)
def test_expired_todo_cannot_be_completed_to_escape_deletion(self):
item_id = self.add_todo(
"过期后不能完成", NOW - 2 * HOUR, NOW - HOUR, created_at=NOW - 2 * DAY
)
with mock.patch("notebook.time.time", return_value=NOW):
with self.assertRaises(ValueError):
self.nb.complete_todo(self.ctx, item_id)
with sqlite3.connect(self.db_path) as con:
self.assertEqual(
con.execute("SELECT COUNT(*) FROM todo_items WHERE id=?", (item_id,)).fetchone()[0],
0,
)
def test_context_limits_and_prominent_background_rule(self):
for index in range(4):
with mock.patch("notebook.time.time", return_value=NOW + index):
self.nb.add_memo(self.ctx, f"备忘录{index}")
for index in range(4):
self.add_todo(f"待办{index}", NOW + (index + 1) * HOUR)
context = self.nb.context_for_chat(1, now=NOW)
self.assertIn("⚠️ 重要规则", context)
self.assertIn("这些仅是背景", context)
self.assertIn("只有用户询问或直接相关时才可参考", context)
self.assertIn("不主动重复、汇报或改变话题", context)
self.assertIn("当前聊天优先,不能喧宾夺主", context)
self.assertNotIn("备忘录0", context)
for index in range(1, 4):
self.assertIn(f"备忘录{index}", context)
for index in range(3):
self.assertIn(f"待办{index}", context)
self.assertNotIn("待办3", context)
self.assertEqual(context.count("|到期 "), 3)
def test_context_overdue_72_hour_rule_query_and_physical_cleanup(self):
created = NOW - 10 * DAY
self.add_todo("逾期任务", NOW - HOUR, NOW + DAY, created)
self.add_todo("边界任务", NOW + 72 * HOUR, NOW + 73 * HOUR, created)
self.add_todo("过远任务", NOW + 73 * HOUR, NOW + 74 * HOUR, created)
expired_id = self.add_todo("过期任务", NOW - 2 * HOUR, NOW - HOUR, created)
completed_id = self.add_todo("已完成任务", NOW + HOUR, NOW + DAY, created)
self.assertTrue(self.nb.complete_todo(self.ctx, completed_id))
context = self.nb.context_for_chat(1, now=NOW)
self.assertIn("【已逾期】逾期任务", context)
self.assertIn("边界任务", context)
self.assertNotIn("过远任务", context)
self.assertNotIn("过期任务", context)
self.assertNotIn("已完成任务", context)
with sqlite3.connect(self.db_path) as con:
self.assertEqual(
con.execute("SELECT COUNT(*) FROM todo_items WHERE id=?", (expired_id,)).fetchone()[0],
0,
)
self.assertIn("逾期任务", self.nb.context_for_chat(1, "逾期", now=NOW))
self.assertIn("逾期任务", self.nb.context_for_chat(1, "不相关的整句聊天", now=NOW))
def test_context_private_isolation_and_empty_result(self):
self.nb.add_memo(self.ctx, "用户一的记录")
self.nb.add_memo(group_ctx(target_id=100, uid=1), "群记录")
self.assertEqual(self.nb.context_for_chat(2, now=NOW), "")
self.assertNotIn("群记录", self.nb.context_for_chat(1, now=NOW))
def test_restart_persistence(self):
memo_id = self.nb.add_memo(self.ctx, "持久备忘录")
todo_id = self.add_todo("持久待办", NOW + DAY)
self.nb.close()
restarted = Notebook(self.db_path)
try:
self.assertEqual(restarted.list_memos(self.ctx)[0]["id"], memo_id["id"])
self.assertEqual(restarted.list_todos(self.ctx)[0]["id"], todo_id)
finally:
restarted.close()
def test_formatters_and_help_distinguish_reminders(self):
with mock.patch("notebook.time.time", return_value=NOW):
memo_id = self.nb.add_memo(self.ctx, "格式")
memo = self.nb.list_memos(self.ctx)[0]
self.assertIn(memo_id["id"], format_memo(memo))
todo_id = self.add_todo("格式待办", NOW + HOUR)
todo = self.nb.list_todos(self.ctx)[0]
self.assertIn(todo_id, format_todo(todo, now=NOW))
self.assertIn("2030-01-02 13:00", format_todo(todo, now=NOW))
self.assertIn("已逾期", format_todo(todo, now=NOW + 2 * HOUR))
self.assertIn("不会到点发送通知", HELP_TEXT)
self.assertIn("定时提醒", HELP_TEXT)
class CleanupWorkerTests(unittest.IsolatedAsyncioTestCase):
async def test_worker_catches_cleanup_exception_and_continues(self):
nb = Notebook(":memory:")
with (
mock.patch.object(
nb, "cleanup_expired", side_effect=[RuntimeError("db busy"), 0]
) as cleanup,
mock.patch(
"notebook.asyncio.sleep", side_effect=[None, asyncio.CancelledError]
),
):
with self.assertRaises(asyncio.CancelledError):
await nb.cleanup_worker(interval=0)
self.assertEqual(cleanup.call_count, 2)
if __name__ == "__main__":
unittest.main()
+93
View File
@@ -0,0 +1,93 @@
"""Notebook tool adapter and lightweight chat-context integration tests."""
from pathlib import Path
import sys
import unittest
from types import SimpleNamespace
from unittest.mock import patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
from agent import ToolContext, ToolExecutor
from chat import engine
class FakeNotebook:
def __init__(self):
self.calls = []
def add_memo(self, ctx, content):
self.calls.append(("add_memo", content))
return {"id": "memo0001", "content": content}
def list_memos(self, ctx, query=None):
return [{"id": "memo0001", "content": "钥匙在抽屉"}]
def delete_memo(self, ctx, item_id):
return {"id": "memo0001", "content": "钥匙在抽屉"}
def add_todo(self, ctx, content, due_at, expires_at=None):
self.calls.append(("add_todo", content, due_at, expires_at))
return {"id": "todo0001", "content": content, "due_at": due_at}
def list_todos(self, ctx, include_completed=False):
return [{"id": "todo0001", "content": "交报告", "due_at": "tomorrow"}]
def complete_todo(self, ctx, item_id):
return {"id": "todo0001", "content": "交报告"}
def delete_todo(self, ctx, item_id):
return {"id": "todo0001", "content": "交报告"}
class NotebookToolTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.fake = FakeNotebook()
module = SimpleNamespace(
notebook=self.fake,
format_memo=lambda item: f"{item['id']} {item['content']}",
format_todo=lambda item: f"{item['id']} {item['content']}",
)
self.executor = ToolExecutor(modules={"memo": module, "todo": module})
self.ctx = ToolContext(
channel=SimpleNamespace(name="tg"), msg_type="private",
user_id=42, target_id=10001,
)
async def test_schema_exposes_memo_and_todo(self):
names = [item["name"] for item in self.executor.prompt_schema(self.ctx)]
self.assertIn("memo", names)
self.assertIn("todo", names)
async def test_memo_add_list_delete(self):
added = await self.executor.execute("memo", {"action": "add", "params": {"content": "钥匙在抽屉"}}, self.ctx)
self.assertTrue(added.ok)
listed = await self.executor.execute("memo", {"action": "list", "params": {}}, self.ctx)
self.assertIn("钥匙在抽屉", listed.safe_message)
deleted = await self.executor.execute("memo", {"action": "delete", "params": {"id": "memo"}}, self.ctx)
self.assertTrue(deleted.ok)
async def test_todo_add_list_complete_delete(self):
added = await self.executor.execute(
"todo", {"action": "add", "params": {"content": "交报告", "due_at": "2026-10-31T18:00:00+08:00"}}, self.ctx
)
self.assertTrue(added.ok)
listed = await self.executor.execute("todo", {"action": "list", "params": {}}, self.ctx)
self.assertIn("交报告", listed.safe_message)
completed = await self.executor.execute("todo", {"action": "complete", "params": {"id": "todo"}}, self.ctx)
self.assertTrue(completed.ok)
deleted = await self.executor.execute("todo", {"action": "delete", "params": {"id": "todo"}}, self.ctx)
self.assertTrue(deleted.ok)
def test_chat_prompt_contains_bounded_notebook_background(self):
block = "【独立备忘录与近期约定】仅背景,当前聊天优先,不能喧宾夺主"
with patch.object(engine, "user_persona_prompt", return_value="persona"), \
patch.object(engine, "load_user_status", return_value={"pause": False, "sleep": False}), \
patch.object(engine.memory_store, "get_relation", return_value=None), \
patch.object(engine.memory_store, "list_entities", return_value=[]), \
patch.object(engine.memory_store, "recall_facts", return_value=[]), \
patch.object(engine.memory_store, "migrate_legacy"), \
patch.object(engine, "get_history_within_budget", return_value=[]), \
patch.object(engine, "_sticker_prompt_summary", return_value=""), \
patch.object(engine.notebook.notebook, "context_for_chat", return_value=block) as context:
prompt = engine.build_system_prompt(42, query="当前聊天")
context.assert_called_once_with(42, query="当前聊天")
self.assertIn(block, prompt)
if __name__ == "__main__":
unittest.main()
+51
View File
@@ -0,0 +1,51 @@
"""Notification delivery must survive chat-model outages with an explicit marker."""
from pathlib import Path
import sys
import unittest
from unittest.mock import AsyncMock, patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
import notification_render
import notifications
from chat import engine
class NotificationApiFallbackTests(unittest.IsolatedAsyncioTestCase):
def event(self):
return notifications.Event(
kind="komari",
dedupe_key="komari:api-test",
legacy_text="[Komari] 服务器「Ama-10」当前处于离线状态。",
uid=1,
payload={"server": "Ama-10", "status": "offline"},
)
async def test_chat_api_failure_returns_raw_notice_with_marker(self):
with patch.object(engine, "call_deepseek", new=AsyncMock(return_value=None)):
rendered = await notification_render.render_event(self.event(), "chat", 10001)
self.assertEqual(
rendered,
"[Komari] 服务器「Ama-10」当前处于离线状态。\n\n【聊天模式 API 暂时不可用】",
)
async def test_command_mode_never_gets_chat_api_marker(self):
with patch.object(engine, "call_deepseek", new=AsyncMock(return_value=None)):
rendered = await notification_render.render_event(self.event(), "command", 10001)
self.assertEqual(rendered, self.event().legacy_text)
async def test_successful_render_has_no_api_marker(self):
with patch.object(engine, "call_deepseek", new=AsyncMock(return_value="Ama-10 掉线了。")):
rendered = await notification_render.render_event(self.event(), "chat", 10001)
self.assertEqual(rendered, "Ama-10 掉线了。")
self.assertNotIn(notification_render.API_UNAVAILABLE_NOTICE, rendered)
def test_marker_is_not_duplicated(self):
text = "原文\n\n" + notification_render.API_UNAVAILABLE_NOTICE
self.assertEqual(notification_render._with_api_notice(text, "chat"), text)
if __name__ == "__main__":
unittest.main()
+221
View File
@@ -0,0 +1,221 @@
import asyncio
from concurrent.futures import ThreadPoolExecutor
import os
import sqlite3
import tempfile
import threading
import unittest
from pathlib import Path
import sys
from unittest.mock import patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
import channel
import identity
# Isolate the module-level NotificationCenter created during import as well as
# the per-test centers; restore the environment immediately for other modules.
_IMPORT_TMP = tempfile.TemporaryDirectory()
_OLD_DB_PATH = os.environ.get("AGENT_DB_PATH")
os.environ["AGENT_DB_PATH"] = str(Path(_IMPORT_TMP.name) / "module-import.db")
import notifications
if _OLD_DB_PATH is None:
os.environ.pop("AGENT_DB_PATH", None)
else:
os.environ["AGENT_DB_PATH"] = _OLD_DB_PATH
from agent.modes import ModeStore
def tearDownModule():
_IMPORT_TMP.cleanup()
class FakeChannel(channel.Channel):
def __init__(self, name, ok=True):
self.name = name
self.ok = ok
self.sent = []
async def send(self, msg_type, target_id, content):
if self.ok:
self.sent.append((msg_type, target_id, content))
return self.ok
class NotificationCenterTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.tmp = tempfile.TemporaryDirectory()
identity.configure(os.path.join(self.tmp.name, "accounts"), admin_tg_ids=[])
self.uid = 9000000001
self.identity_patcher = patch.object(identity, "push_targets", return_value=[("tg", 10000000001)])
self.identity_patcher.start()
self.center = notifications.NotificationCenter(os.path.join(self.tmp.name, "events.db"))
self.modes = ModeStore(os.path.join(self.tmp.name, "events.db"))
self.tg = FakeChannel("tg")
channel.register_channel(self.tg)
self.acks = []
self.center.register_acknowledger("mail", self.acks.append)
async def asyncTearDown(self):
channel.unregister_channel("tg")
self.identity_patcher.stop()
self.tmp.cleanup()
def event(self):
return notifications.Event(
kind="mail", dedupe_key="mail:1", uid=self.uid,
legacy_text="#2645 raw",
payload={"mid": 2645, "addr": "a@example.test", "url": "https://mail.example.test"},
)
async def test_chat_render_delivery_ack_and_dedupe(self):
self.modes.set_mode("tg", self.uid, "chat")
async def renderer(event, mode, target):
return "主人,邮件 2645 到了:https://mail.example.test"
self.center.configure(renderer)
with patch("agent.modes.ModeStore", return_value=self.modes):
event_id = self.center.publish(self.event())
self.assertEqual(self.center.publish(self.event()), event_id)
count = await self.center.deliver_due()
self.assertEqual(count, 1)
self.assertEqual(len(self.tg.sent), 1)
self.assertIn("主人", self.tg.sent[0][2])
self.assertEqual(len(self.acks), 1)
async def test_command_mode_uses_legacy_and_retries(self):
self.tg.ok = False
with patch("agent.modes.ModeStore", return_value=self.modes):
self.center.publish(self.event())
self.assertEqual(await self.center.deliver_due(), 0)
self.tg.ok = True
# Make the persisted row immediately due again.
import sqlite3
with sqlite3.connect(self.center.db_path) as con:
con.execute("UPDATE notification_deliveries SET next_attempt_at=0")
self.assertEqual(await self.center.deliver_due(), 1)
self.assertEqual(self.tg.sent[0][2], "#2645 raw")
async def test_chat_event_waits_for_active_conversation(self):
self.modes.set_mode("tg", self.uid, "chat")
async def renderer(event, mode, target):
return "主人,邮件 2645 到了:https://mail.example.test"
self.center.configure(renderer)
with patch("agent.modes.ModeStore", return_value=self.modes):
self.center.begin_conversation(self.uid)
self.center.publish(self.event())
self.assertEqual(await self.center.deliver_due(), 0)
self.assertEqual(self.tg.sent, [])
self.center.end_conversation(self.uid)
self.assertEqual(await self.center.deliver_due(), 1)
self.assertEqual(len(self.tg.sent), 1)
async def test_zero_target_publish_is_rejected_and_repeat_adds_bindings(self):
with patch.object(identity, "push_targets", return_value=[]):
self.assertIsNone(self.center.publish(self.event()))
with sqlite3.connect(self.center.db_path) as con:
self.assertEqual(con.execute("SELECT COUNT(*) FROM notification_events").fetchone()[0], 1)
self.assertEqual(con.execute("SELECT COUNT(*) FROM notification_deliveries").fetchone()[0], 0)
event_id = self.center.publish(self.event())
self.assertIsNotNone(event_id)
with patch.object(
identity, "push_targets",
return_value=[("tg", 10000000001), ("qq", 20002)],
):
self.assertEqual(self.center.publish(self.event()), event_id)
with sqlite3.connect(self.center.db_path) as con:
targets = con.execute(
"SELECT channel,target_id FROM notification_deliveries ORDER BY channel"
).fetchall()
self.assertEqual(targets, [("qq", 20002), ("tg", 10000000001)])
async def test_same_key_concurrent_publish_uses_one_event(self):
event = notifications.Event(
kind="mail", dedupe_key="mail:concurrent", legacy_text="raw",
channel="tg", msg_type="private", target_id=10001,
)
other = notifications.NotificationCenter(self.center.db_path)
barrier = threading.Barrier(2)
def publish(center):
barrier.wait()
return center.publish(event)
with patch.object(notifications.NotificationCenter, "_mode", return_value="command"):
with ThreadPoolExecutor(max_workers=2) as pool:
ids = list(pool.map(publish, (self.center, other)))
self.assertEqual(ids[0], ids[1])
with sqlite3.connect(self.center.db_path) as con:
self.assertEqual(con.execute("SELECT COUNT(*) FROM notification_events").fetchone()[0], 1)
self.assertEqual(con.execute("SELECT COUNT(*) FROM notification_deliveries").fetchone()[0], 1)
async def test_concurrent_post_chat_flush_claims_delivery_once(self):
self.center.publish(self.event())
other = notifications.NotificationCenter(self.center.db_path)
entered = asyncio.Event()
release = asyncio.Event()
async def controlled_send(ch, msg_type, target, content, mirror=False):
entered.set()
await release.wait()
self.tg.sent.append((msg_type, target, content))
return True
with patch("notifications.send_message", side_effect=controlled_send):
first = asyncio.create_task(self.center.deliver_due())
await asyncio.wait_for(entered.wait(), timeout=2)
self.assertEqual(await other.deliver_due(), 0)
release.set()
self.assertEqual(await first, 1)
self.assertEqual(len(self.tg.sent), 1)
self.assertEqual(len(self.acks), 1)
async def test_expired_claim_lease_recovers_after_worker_crash(self):
self.center.publish(self.event())
with sqlite3.connect(self.center.db_path) as con:
con.execute(
"UPDATE notification_deliveries SET claim_owner='crashed',claim_until=0"
)
self.assertEqual(await self.center.deliver_due(), 1)
self.assertEqual(len(self.tg.sent), 1)
with sqlite3.connect(self.center.db_path) as con:
owner, until = con.execute(
"SELECT claim_owner,claim_until FROM notification_deliveries"
).fetchone()
self.assertIsNone(owner)
self.assertIsNone(until)
async def test_disabled_target_is_suppressed_and_republish_reenables(self):
event_id = self.center.publish(self.event())
with patch.object(identity, "push_targets", return_value=[]):
self.assertEqual(await self.center.deliver_due(), 0)
self.assertEqual(self.tg.sent, [])
with sqlite3.connect(self.center.db_path) as con:
status = con.execute("SELECT status FROM notification_deliveries").fetchone()[0]
self.assertEqual(status, "suppressed")
self.assertEqual(self.center.publish(self.event()), event_id)
self.assertEqual(await self.center.deliver_due(), 1)
self.assertEqual(len(self.tg.sent), 1)
async def test_active_head_does_not_block_later_deliverable_event(self):
first = notifications.Event(
kind="mail", dedupe_key="mail:first", uid=self.uid,
legacy_text="first", created_at=1,
)
second = notifications.Event(
kind="mail", dedupe_key="mail:second", uid=self.uid + 1,
legacy_text="second", created_at=2,
)
self.modes.set_mode("tg", self.uid, "chat")
with patch("agent.modes.ModeStore", return_value=self.modes):
self.center.publish(first)
self.center.publish(second)
self.center.begin_conversation(self.uid)
self.assertEqual(await self.center.deliver_due(limit=1), 1)
self.assertEqual([item[2] for item in self.tg.sent], ["second"])
if __name__ == "__main__":
unittest.main()
+62
View File
@@ -0,0 +1,62 @@
"""MyPixiv limited placeholder responses must never be delivered as artwork."""
import hashlib
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import AsyncMock, patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
import pixiv
class FakeResponse:
def __init__(self, body):
self.status_code = 200
self.headers = {"Content-Type": "image/png"}
self.body = body
self.closed = False
def iter_content(self, size):
yield self.body[:3]
yield self.body[3:]
def close(self):
self.closed = True
class PixivRestrictedTests(unittest.IsolatedAsyncioTestCase):
async def test_placeholder_is_deleted_and_raises_restricted(self):
body = b"my-pixiv-limited-placeholder"
response = FakeResponse(body)
with tempfile.TemporaryDirectory() as directory, \
patch.object(pixiv, "PIXIV_CACHE_DIR", directory), \
patch.object(pixiv, "RESTRICTED_IMAGE_SHA256", {hashlib.sha256(body).hexdigest()}), \
patch("requests.get", return_value=response):
with self.assertRaises(pixiv.PixivRestrictedError):
pixiv.download_image(148292005)
self.assertEqual(list(Path(directory).iterdir()), [])
self.assertTrue(response.closed)
async def test_handler_returns_permission_error_without_image(self):
sender = AsyncMock(return_value=True)
with patch.object(pixiv, "_load_proxy_ports", return_value=[]), \
patch.object(pixiv, "download_image", side_effect=pixiv.PixivRestrictedError("limited")), \
patch("channel.send_message", new=sender):
await pixiv.handle_pixiv(None, "private", 10001, 1, ".pixiv 148292005")
self.assertEqual(sender.await_count, 2)
for call in sender.await_args_list:
content = call.args[3]
self.assertIsInstance(content, str)
self.assertIn("MyPixiv 限定", sender.await_args.args[3])
def test_known_placeholder_hash_is_registered(self):
self.assertIn(
"caa0bd60e0a0c28a4bd80b6b7024fb4956ffff7c536f4fe9d2d2588b660524c7",
pixiv.RESTRICTED_IMAGE_SHA256,
)
if __name__ == "__main__":
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
"""Prompt-cache ordering tests: stable instructions first, time in user messages."""
from pathlib import Path
import sys
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
from agent import ToolContext
from chat import engine
class PromptCacheOrderTests(unittest.IsolatedAsyncioTestCase):
def build_private(self):
channel = SimpleNamespace(name="tg", is_admin=lambda uid: True)
ctx = ToolContext(channel=channel, msg_type="private", user_id=42, target_id=10001, is_admin=True)
with patch.object(engine, "user_persona_prompt", return_value="PERSONA"), \
patch.object(engine, "load_user_status", return_value={"pause": False, "sleep": False}), \
patch.object(engine.memory_store, "get_relation", return_value=None), \
patch.object(engine.memory_store, "list_entities", return_value=[]), \
patch.object(engine.memory_store, "recall_facts", return_value=[(1, "长期事实", 5)]), \
patch.object(engine.memory_store, "migrate_legacy"), \
patch.object(engine, "get_history_within_budget", return_value=["最近历史"]), \
patch.object(engine.notebook.notebook, "context_for_chat", return_value="笔记本背景"), \
patch.object(engine, "_sticker_prompt_summary", return_value="贴纸场景"):
return engine.build_system_prompt(42, query="当前问题", tool_context=ctx)
def test_prompt_budget_is_128k(self):
self.assertEqual(engine.PROMPT_TOKEN_BUDGET, 131072)
def test_private_stable_instructions_precede_dynamic_sections(self):
prompt = self.build_private()
self.assertNotIn("现在是 ", prompt)
agent = prompt.index("当你回复时")
memory = prompt.index("【长期记忆】")
status = prompt.index("当前用户状态")
history = prompt.index("以下是最近你和用户的聊天记录")
self.assertLess(agent, memory)
self.assertLess(memory, status)
self.assertLess(status, history)
self.assertIn("你还可以调用以下 utility 工具", prompt[:history])
def test_sticker_prompt_prefers_meaning_over_description(self):
expected = "理解其情绪、含义和使用场景即可,不要主动描述画面内容"
self.assertIn(expected, engine.load_instruction("agent_instructions.txt"))
self.assertIn(expected, engine.load_instruction("group_instructions.txt"))
def test_time_is_prepended_to_user_message(self):
message = engine._message_with_time("用户输入")
self.assertTrue(message.startswith("现在是 "))
self.assertTrue(message.endswith("\n用户输入"))
async def test_group_prompt_has_stable_prefix_and_time_in_user_message(self):
response = '{"message":[{"content":"ok"}],"tools":[],"need_reasoning":false}'
model = AsyncMock(return_value=response)
with patch.object(engine, "get_recent_group_history", return_value=["群历史"]), \
patch.object(engine, "call_deepseek", new=model), \
patch.object(engine, "send_group_message", new=AsyncMock(return_value=True)), \
patch.object(engine, "append_group_history"):
await engine._call_group_model_and_reply_impl(None, 1, "群消息", 0)
system, user = model.await_args.args[:2]
self.assertNotIn("现在是 ", system)
self.assertTrue(user.startswith("现在是 "))
self.assertLess(system.index("当你回复时"), system.index("以下是本群的最近聊天记录"))
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
"""Hybrid reasoning policy: casual chat off, Agent planning on."""
from pathlib import Path
import sys
import unittest
from unittest.mock import AsyncMock, patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
from chat import engine
class ReasoningPolicyTests(unittest.IsolatedAsyncioTestCase):
def test_reasoning_parameters_are_explicit(self):
self.assertEqual(
engine._reasoning_params(False),
{"thinking": {"type": "disabled"}},
)
self.assertEqual(
engine._reasoning_params(True),
{"thinking": {"type": "enabled"}, "reasoning_effort": "high"},
)
def test_any_tool_call_requires_agent_reasoning(self):
context = object()
for tool in ("setu", "web_search", "fetch", "mail"):
parsed = {"understood": True, "tools": [{"name": tool}]}
self.assertTrue(engine._tool_plan_requires_reasoning(parsed, context))
self.assertFalse(engine._tool_plan_requires_reasoning({"tools": []}, context))
self.assertFalse(engine._tool_plan_requires_reasoning({"understood": False, "tools": [{}]}, context))
self.assertFalse(engine._tool_plan_requires_reasoning({"tools": [{}]}, None))
def test_model_self_assessment_can_request_reasoning(self):
context = object()
parsed = {"need_reasoning": True, "tools": []}
self.assertTrue(engine._chat_requires_reasoning(parsed, context, False))
self.assertFalse(engine._chat_requires_reasoning(parsed, context, True))
self.assertFalse(engine._chat_requires_reasoning({"need_reasoning": False, "tools": []}, context, False))
prompt = engine.load_instruction("agent_instructions.txt")
self.assertIn("need_reasoning", prompt)
self.assertIn("没有把握", prompt)
def test_last_json_retry_uses_reasoning(self):
self.assertFalse(engine._json_retry_uses_reasoning(0))
self.assertTrue(engine._json_retry_uses_reasoning(engine.MAX_RETRIES - 2))
self.assertFalse(engine._json_retry_uses_reasoning(engine.MAX_RETRIES - 1))
def test_reminder_tone_forbids_threatening_or_startling_users(self):
prompt = engine.BASE_PROMPT
self.assertIn("威胁、恐吓、惊吓", prompt)
self.assertIn("底色必须温和、让人安心", prompt)
self.assertIn("别被白花吓一跳", prompt)
self.assertIn("只响这一次", prompt)
self.assertIn("只响一次", prompt)
self.assertIn("今晚八点提醒你吃饭", prompt)
async def test_plain_chat_defaults_to_non_thinking(self):
official = AsyncMock(return_value="ok")
with patch.object(engine, "_modelscope_available", return_value=False), \
patch.object(engine, "_build_user_content", new=AsyncMock(return_value="聊天")), \
patch.object(engine, "_call_deepseek_official", new=official):
await engine.call_deepseek("system", "聊天")
self.assertIs(official.await_args.kwargs["reasoning"], False)
async def test_agent_request_explicitly_uses_high_reasoning(self):
official = AsyncMock(return_value="ok")
with patch.object(engine, "_modelscope_available", return_value=False), \
patch.object(engine, "_build_user_content", new=AsyncMock(return_value="调用工具")), \
patch.object(engine, "_call_deepseek_official", new=official):
await engine.call_deepseek("system", "调用工具", reasoning=True)
self.assertIs(official.await_args.kwargs["reasoning"], True)
async def test_modelscope_receives_same_reasoning_flag(self):
modelscope = AsyncMock(return_value="ok")
with patch.object(engine, "_modelscope_available", return_value=True), \
patch.object(engine, "_build_user_content", new=AsyncMock(return_value="聊天")), \
patch.object(engine, "_call_modelscope", new=modelscope), \
patch.object(engine, "_modelscope_mark_success"):
await engine.call_deepseek("system", "聊天", reasoning=True)
self.assertIs(modelscope.await_args.kwargs["reasoning"], True)
if __name__ == "__main__":
unittest.main()
+314
View File
@@ -0,0 +1,314 @@
import os
import sqlite3
import sys
import tempfile
import unittest
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
from zoneinfo import ZoneInfo
# The module-level Scheduler must use a temporary DB. Prefer the real
# notifications module in deployment; only standalone local runs need a stub.
_IMPORT_TMP = tempfile.TemporaryDirectory()
_OLD_DB_PATH = os.environ.get("AGENT_DB_PATH")
os.environ["AGENT_DB_PATH"] = str(Path(_IMPORT_TMP.name) / "module-import.db")
BACKEND = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND / "bot-utility"))
try:
import notifications # noqa: F401
except ModuleNotFoundError:
notifications_stub = ModuleType("notifications")
@dataclass
class FakeEvent:
kind: str
dedupe_key: str
legacy_text: str
uid: int | None = None
payload: dict = field(default_factory=dict)
channel: str | None = None
msg_type: str | None = None
target_id: int | None = None
notifications_stub.Event = FakeEvent
notifications_stub.center = None
sys.modules["notifications"] = notifications_stub
from scheduler import HELP_TEXT, Scheduler, format_task
SHANGHAI = ZoneInfo("Asia/Shanghai")
NOW = datetime(2026, 10, 30, 13, 56, 16, tzinfo=SHANGHAI)
def tearDownModule():
if _OLD_DB_PATH is None:
os.environ.pop("AGENT_DB_PATH", None)
else:
os.environ["AGENT_DB_PATH"] = _OLD_DB_PATH
_IMPORT_TMP.cleanup()
class FakeCenter:
"""Small persistent-dedupe fake; never sends a real notification."""
def __init__(self):
self.events = []
self.keys = set()
self.accept = True
def publish(self, event):
if not self.accept:
return None
if event.dedupe_key in self.keys:
return "duplicate"
self.keys.add(event.dedupe_key)
self.events.append(event)
return "new"
class SchedulerTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = str(Path(self.tmp.name) / "tasks.db")
self.scheduler = Scheduler(self.db_path)
self.center = FakeCenter()
self.private = SimpleNamespace(
user_id=10001,
channel=SimpleNamespace(name="tg"),
msg_type="private",
target_id=10001,
)
self.other_private = SimpleNamespace(
user_id=10002,
channel=SimpleNamespace(name="tg"),
msg_type="private",
target_id=10002,
)
self.group = SimpleNamespace(
user_id=10001,
channel=SimpleNamespace(name="tg"),
msg_type="group",
target_id=20001,
)
def tearDown(self):
self.tmp.cleanup()
def create(self, ctx=None, **params):
return self.scheduler.create_from_params(ctx or self.private, params, now=NOW)
def test_once_naive_and_aware_run_at(self):
naive = self.create(message="naive", kind="once", run_at="2026-10-30T14:00:00")
expected_naive = datetime(2026, 10, 30, 14, 0, tzinfo=SHANGHAI).timestamp()
self.assertEqual(naive["next_run_at"], expected_naive)
self.assertEqual(naive["next_run_text"], "2026-10-30 14:00:00")
self.assertEqual(len(naive["id"]), 8)
int(naive["id"], 16)
aware = self.create(message="aware", kind="once", run_at="2026-10-30T06:30:00+00:00")
self.assertEqual(aware["next_run_text"], "2026-10-30 14:30:00")
def test_daily_calculation_and_rollover(self):
later = self.create(message="later", kind="daily", time_of_day="14:00:01")
self.assertEqual(later["next_run_text"], "2026-10-30 14:00:01")
self.assertEqual(later["time_of_day"], "14:00:01")
tomorrow = self.create(message="tomorrow", kind="daily", time_of_day="13:00")
self.assertEqual(tomorrow["next_run_text"], "2026-10-31 13:00:00")
self.assertEqual(tomorrow["time_of_day"], "13:00:00")
def test_weekly_calculation_and_weekday_deduplication(self):
# 2026-10-30 is Friday (ISO weekday 5); Friday noon has passed.
monday = self.create(
message="weekly", kind="weekly", time_of_day="12:00", weekdays=[5, 1, 5]
)
self.assertEqual(monday["weekdays"], [1, 5])
self.assertEqual(monday["next_run_text"], "2026-11-02 12:00:00")
friday = self.create(
message="friday", kind="weekly", time_of_day="14:00", weekdays=[5]
)
self.assertEqual(friday["next_run_text"], "2026-10-30 14:00:00")
def test_invalid_params(self):
cases = [
{"message": "x", "kind": "once", "run_at": "2026-10-31", "extra": 1},
{"message": " ", "kind": "daily", "time_of_day": "12:00"},
{"message": "x" * 1001, "kind": "daily", "time_of_day": "12:00"},
{"message": "x", "kind": "monthly"},
{"message": "x", "kind": "once"},
{"message": "x", "kind": "once", "run_at": "not-a-time"},
{"message": "x", "kind": "once", "run_at": "2026-10-30T13:56:16+08:00"},
{"message": "x", "kind": "daily"},
{"message": "x", "kind": "daily", "time_of_day": "24:00"},
{"message": "x", "kind": "daily", "time_of_day": "12:60"},
{"message": "x", "kind": "weekly", "time_of_day": "12:00"},
{"message": "x", "kind": "weekly", "time_of_day": "12:00", "weekdays": []},
{"message": "x", "kind": "weekly", "time_of_day": "12:00", "weekdays": [0]},
{"message": "x", "kind": "weekly", "time_of_day": "12:00", "weekdays": [True]},
{"message": "x", "kind": "weekly", "time_of_day": "12:00", "weekdays": "1"},
]
for params in cases:
with self.subTest(params=params):
with self.assertRaises(ValueError):
self.create(**params)
self.assertEqual(self.scheduler.list_for_context(self.private), [])
def test_list_is_context_scoped_and_sorted(self):
late = self.create(message="late", kind="daily", time_of_day="18:00")
early = self.create(message="early", kind="daily", time_of_day="09:00")
self.create(ctx=self.other_private, message="other user", kind="daily", time_of_day="10:00")
group_task = self.create(ctx=self.group, message="group", kind="daily", time_of_day="11:00")
tasks = self.scheduler.list_for_context(self.private)
# Today's 18:00 is due before tomorrow's 09:00.
self.assertEqual([task["id"] for task in tasks], [late["id"], early["id"]])
self.assertEqual(self.scheduler.list_for_context(self.other_private)[0]["message"], "other user")
self.assertEqual([task["id"] for task in self.scheduler.list_for_context(self.group)], [group_task["id"]])
other_group = SimpleNamespace(
user_id=10001, channel=SimpleNamespace(name="tg"), msg_type="group", target_id=20002
)
self.assertEqual(self.scheduler.list_for_context(other_group), [])
def test_cancel_requires_unique_prefix_and_same_context(self):
with patch("scheduler.secrets.token_hex", side_effect=["aaaa0001", "aaaa0002"]):
first = self.create(message="first", kind="daily", time_of_day="10:00")
second = self.create(message="second", kind="daily", time_of_day="11:00")
with self.assertRaises(ValueError):
self.scheduler.cancel_for_context(self.other_private, first["id"])
with self.assertRaises(ValueError):
self.scheduler.cancel_for_context(self.private, "aaaa")
cancelled = self.scheduler.cancel_for_context(self.private, "aaaa0001")
self.assertEqual(cancelled["status"], "cancelled")
remaining = self.scheduler.list_for_context(self.private)
self.assertEqual([task["id"] for task in remaining], [second["id"]])
with self.assertRaises(ValueError):
self.scheduler.cancel_for_context(self.private, first["id"])
def test_fire_once_publishes_event_and_completes(self):
task = self.create(message="喝水", kind="once", run_at="2026-10-30T14:00:00")
fire_time = datetime(2026, 10, 30, 14, 0, 1, tzinfo=SHANGHAI)
self.assertEqual(self.scheduler.fire_due(now=fire_time, center=self.center), 1)
self.assertEqual(len(self.center.events), 1)
event = self.center.events[0]
self.assertEqual(event.kind, "reminder")
self.assertEqual(event.uid, self.private.user_id)
self.assertEqual(event.legacy_text, "定时提醒:喝水")
self.assertEqual(event.payload["task_id"], task["id"])
self.assertEqual(event.payload["message"], "喝水")
self.assertEqual(event.payload["kind"], "once")
self.assertEqual(event.payload["scheduled_at"], task["next_run_at"])
self.assertEqual(event.dedupe_key, f"reminder:{task['id']}:{int(task['next_run_at'])}")
self.assertEqual(self.scheduler.list_for_context(self.private), [])
with sqlite3.connect(self.db_path) as con:
status = con.execute("SELECT status FROM scheduled_tasks WHERE id=?", (task["id"],)).fetchone()[0]
self.assertEqual(status, "completed")
self.assertEqual(self.scheduler.fire_due(now=fire_time, center=self.center), 0)
self.assertEqual(len(self.center.events), 1)
def test_fire_group_event_uses_group_target(self):
self.create(ctx=self.group, message="群提醒", kind="once", run_at="2026-10-30T14:00:00")
self.scheduler.fire_due(now=NOW.timestamp() + 600, center=self.center)
event = self.center.events[0]
self.assertIsNone(event.uid)
self.assertEqual((event.channel, event.msg_type, event.target_id), ("tg", "group", 20001))
def test_repeating_tasks_advance_past_now_without_catchup_burst(self):
daily = self.create(message="daily", kind="daily", time_of_day="14:00")
weekly = self.create(
message="weekly", kind="weekly", time_of_day="14:00", weekdays=[5]
)
# Several daily occurrences and the first weekly occurrence were missed.
later = datetime(2026, 11, 2, 15, 0, tzinfo=SHANGHAI)
self.assertEqual(self.scheduler.fire_due(now=later, center=self.center), 2)
self.assertEqual(len(self.center.events), 2)
tasks = {task["id"]: task for task in self.scheduler.list_for_context(self.private)}
self.assertEqual(tasks[daily["id"]]["next_run_text"], "2026-11-03 14:00:00")
self.assertEqual(tasks[weekly["id"]]["next_run_text"], "2026-11-06 14:00:00")
self.assertEqual(self.scheduler.fire_due(now=later, center=self.center), 0)
self.assertEqual(len(self.center.events), 2)
def test_restart_persistence_and_publish_dedupe_after_crash_window(self):
task = self.create(message="persist", kind="once", run_at="2026-10-30T14:00:00")
due = datetime(2026, 10, 30, 14, 0, 1, tzinfo=SHANGHAI)
restarted = Scheduler(self.db_path)
self.assertEqual([item["id"] for item in restarted.list_for_context(self.private)], [task["id"]])
restarted.fire_due(now=due, center=self.center)
self.assertEqual(len(self.center.events), 1)
# Simulate a crash after publish but before the task status commit.
with sqlite3.connect(self.db_path) as con:
con.execute(
"UPDATE scheduled_tasks SET status='active',next_run_at=? WHERE id=?",
(task["next_run_at"], task["id"]),
)
restarted_again = Scheduler(self.db_path)
restarted_again.fire_due(now=due, center=self.center)
self.assertEqual(len(self.center.events), 1, "same dedupe key must not publish twice")
self.assertEqual(restarted_again.list_for_context(self.private), [])
def test_zero_delivery_does_not_advance_and_later_retry_is_accepted(self):
task = self.create(message="等待绑定", kind="once", run_at="2026-10-30T14:00:00")
due = datetime(2026, 10, 30, 14, 0, 1, tzinfo=SHANGHAI)
self.center.accept = False
self.assertEqual(self.scheduler.fire_due(now=due, center=self.center), 0)
self.assertEqual(self.center.events, [])
remaining = self.scheduler.list_for_context(self.private)
self.assertEqual([item["id"] for item in remaining], [task["id"]])
self.assertEqual(remaining[0]["next_run_at"], task["next_run_at"])
self.center.accept = True
self.assertEqual(self.scheduler.fire_due(now=due, center=self.center), 1)
self.assertEqual(len(self.center.events), 1)
self.assertEqual(self.scheduler.list_for_context(self.private), [])
def test_cancel_after_select_before_claim_prevents_publish(self):
task = self.create(message="不要发送", kind="once", run_at="2026-10-30T14:00:00")
due = datetime(2026, 10, 30, 14, 0, 1, tzinfo=SHANGHAI)
original_claim = self.scheduler._claim_task
def cancel_then_claim(row, current, owner):
self.scheduler.cancel_for_context(self.private, row["id"])
return original_claim(row, current, owner)
with patch.object(self.scheduler, "_claim_task", side_effect=cancel_then_claim):
self.assertEqual(self.scheduler.fire_due(now=due, center=self.center), 0)
self.assertEqual(self.center.events, [])
with sqlite3.connect(self.db_path) as con:
status = con.execute(
"SELECT status FROM scheduled_tasks WHERE id=?", (task["id"],)
).fetchone()[0]
self.assertEqual(status, "cancelled")
def test_expired_firing_lease_recovers_without_duplicate_event(self):
task = self.create(message="崩溃恢复", kind="once", run_at="2026-10-30T14:00:00")
due = datetime(2026, 10, 30, 14, 0, 1, tzinfo=SHANGHAI)
with sqlite3.connect(self.db_path) as con:
con.execute(
"""UPDATE scheduled_tasks
SET status='firing',firing_owner='crashed',firing_until=0
WHERE id=?""",
(task["id"],),
)
self.assertEqual(self.scheduler.fire_due(now=due, center=self.center), 1)
self.assertEqual(len(self.center.events), 1)
self.assertEqual(self.scheduler.list_for_context(self.private), [])
def test_format_task_and_help_text(self):
task = self.create(message="写周报", kind="weekly", time_of_day="18:00", weekdays=[1, 5])
text = format_task(task)
self.assertIn(task["id"], text)
self.assertIn("每周", text)
self.assertIn("写周报", text)
self.assertIn(task["next_run_text"], text)
self.assertIn("自然语言", HELP_TEXT)
self.assertIn("查看", HELP_TEXT)
self.assertIn("取消", HELP_TEXT)
if __name__ == "__main__":
unittest.main()
+70
View File
@@ -0,0 +1,70 @@
"""Agent schedule-tool adapter tests with an isolated fake scheduler."""
from pathlib import Path
import sys
import unittest
from types import SimpleNamespace
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
from agent import ToolContext, ToolExecutor
class FakeScheduler:
def __init__(self):
self.created = None
self.cancelled = None
def create_from_params(self, ctx, params):
self.created = (ctx, params)
return {"id": "abc12345", "message": params["message"], "kind": params["kind"], "next_run_text": "今晚八点"}
def list_for_context(self, ctx):
return [{"id": "abc12345", "message": "吃饭", "kind": "once", "next_run_text": "今晚八点"}]
def cancel_for_context(self, ctx, task_id):
self.cancelled = (ctx, task_id)
return {"id": "abc12345", "message": "吃饭"}
class ScheduleToolTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.fake = FakeScheduler()
module = SimpleNamespace(scheduler=self.fake, format_task=lambda task: f"{task['id']} {task['message']} {task.get('next_run_text', '')}")
self.executor = ToolExecutor(modules={"schedule": module})
self.ctx = ToolContext(
channel=SimpleNamespace(name="tg"), msg_type="private",
user_id=1, target_id=10001, is_admin=False,
)
async def test_schema_exposes_schedule_to_normal_users(self):
names = [item["name"] for item in self.executor.prompt_schema(self.ctx)]
self.assertIn("schedule", names)
async def test_create_uses_current_conversation(self):
result = await self.executor.execute(
"schedule",
{"action": "create", "params": {"message": "吃饭", "kind": "once", "run_at": "2026-10-30T20:00:00+08:00"}},
self.ctx,
)
self.assertTrue(result.ok)
self.assertIs(self.fake.created[0], self.ctx)
self.assertEqual(self.fake.created[1]["message"], "吃饭")
async def test_list_and_cancel(self):
listed = await self.executor.execute("schedule", {"action": "list", "params": {}}, self.ctx)
self.assertIn("abc12345", listed.safe_message)
cancelled = await self.executor.execute(
"schedule", {"action": "cancel", "params": {"id": "abc"}}, self.ctx
)
self.assertTrue(cancelled.ok)
self.assertEqual(self.fake.cancelled, (self.ctx, "abc"))
async def test_cancel_rejects_extra_params(self):
result = await self.executor.execute(
"schedule", {"action": "cancel", "params": {"id": "abc", "uid": 2}}, self.ctx
)
self.assertFalse(result.ok)
self.assertEqual(result.code, "invalid_args")
if __name__ == "__main__":
unittest.main()
+132
View File
@@ -0,0 +1,132 @@
"""Adversarial tests for command authorization and Telegram sticker delivery."""
import asyncio
import importlib.util
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest import mock
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
def load_dispatch():
"""Import dispatch without optional network dependencies of unused modules."""
stubs = {}
for name in (
"setu", "jm", "pixiv", "bilibili", "mail", "live_monitor", "ginkgostu",
"identity", "identity_cmd", "for_admin_komari_agent", "for_admin_catch_log",
"mode_commands",
):
stubs[name] = types.ModuleType(name)
help_ui = types.ModuleType("help_ui")
help_ui.command_index = lambda: "help"
stubs["help_ui"] = help_ui
deepseek = types.ModuleType("deepseek")
deepseek.__path__ = []
deepseek.deepseek_balance = types.ModuleType("deepseek.deepseek_balance")
stubs["deepseek"] = deepseek
stubs["deepseek.deepseek_balance"] = deepseek.deepseek_balance
spec = importlib.util.spec_from_file_location(
"dispatch_security_test", BASE / "bot-utility" / "dispatch.py",
)
module = importlib.util.module_from_spec(spec)
with mock.patch.dict(sys.modules, stubs):
spec.loader.exec_module(module)
return module
class ChatAdminTests(unittest.IsolatedAsyncioTestCase):
async def test_chat_on_off_requires_admin(self):
dispatch = load_dispatch()
channel = types.SimpleNamespace(is_admin=mock.Mock(return_value=False))
engine = types.SimpleNamespace(set_chat_enabled=mock.Mock())
chat = types.ModuleType("chat")
chat.engine = engine
dispatch.send_message = mock.AsyncMock()
with mock.patch.dict(sys.modules, {"chat": chat}):
handled = await dispatch._handle_chat_legacy(channel, "group", 20, 10, ".chat on")
self.assertTrue(handled)
engine.set_chat_enabled.assert_not_called()
dispatch.send_message.assert_awaited_once_with(channel, "group", 20, "该指令仅管理员可用")
async def test_admin_can_toggle_chat(self):
dispatch = load_dispatch()
channel = types.SimpleNamespace(is_admin=mock.Mock(return_value=True))
engine = types.SimpleNamespace(set_chat_enabled=mock.Mock())
chat = types.ModuleType("chat")
chat.engine = engine
dispatch.send_message = mock.AsyncMock()
with mock.patch.dict(sys.modules, {"chat": chat}):
for arg, enabled in (("on", True), ("off", False)):
with self.subTest(arg=arg):
handled = await dispatch._handle_chat_legacy(
channel, "private", 10, 10, ".chat " + arg,
)
self.assertTrue(handled)
engine.set_chat_enabled.assert_called_with(enabled)
self.assertEqual(engine.set_chat_enabled.call_count, 2)
try:
from aiogram.types import FSInputFile
except ModuleNotFoundError:
aiogram = types.ModuleType("aiogram")
aiogram.__path__ = []
aiogram_types = types.ModuleType("aiogram.types")
class FSInputFile:
def __init__(self, path):
self.path = path
aiogram_types.FSInputFile = FSInputFile
aiogram.types = aiogram_types
sys.modules["aiogram"] = aiogram
sys.modules["aiogram.types"] = aiogram_types
from tg_channel import TGChannel
class TelegramStickerTests(unittest.IsolatedAsyncioTestCase):
async def test_agent_sticker_uses_sticker_api_not_photo(self):
with tempfile.TemporaryDirectory() as directory:
image = Path(directory) / "sticker.webp"
image.write_bytes(b"sticker")
bot = types.SimpleNamespace(
send_sticker=mock.AsyncMock(return_value=object()),
send_photo=mock.AsyncMock(return_value=object()),
send_document=mock.AsyncMock(return_value=object()),
)
channel = TGChannel(bot, admin_ids=[])
sent = await channel.send("private", 123, [{
"type": "image", "data": {"file": image.as_uri(), "sub_type": 1},
}])
self.assertTrue(sent)
bot.send_sticker.assert_awaited_once()
self.assertEqual(bot.send_sticker.await_args.kwargs["chat_id"], 123)
bot.send_photo.assert_not_awaited()
bot.send_document.assert_not_awaited()
async def test_regular_image_still_uses_photo_api(self):
with tempfile.TemporaryDirectory() as directory:
image = Path(directory) / "image.jpg"
image.write_bytes(b"image")
bot = types.SimpleNamespace(
send_sticker=mock.AsyncMock(return_value=object()),
send_photo=mock.AsyncMock(return_value=object()),
send_document=mock.AsyncMock(return_value=object()),
)
channel = TGChannel(bot, admin_ids=[])
sent = await channel.send("private", 123, [{
"type": "image", "data": {"file": image.as_uri()},
}])
self.assertTrue(sent)
bot.send_photo.assert_awaited_once()
bot.send_sticker.assert_not_awaited()
if __name__ == "__main__":
unittest.main()
+139
View File
@@ -0,0 +1,139 @@
"""Skill orchestration tests with fake models, isolated skill directories and no sends."""
import json
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / 'bot-utility'), str(BASE)]
import skill_registry
import skill_runtime
import help_ui
import notification_render
import notifications
import agent_loop
from chat import engine
class SkillIntegrationTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
base = Path(self.tmp.name)
root = base / 'skills'
self.body = 'SPECIFIC_STYLE_RULE: 用户称呼优先于默认规范。'
for name in ('language', 'result-style'):
folder = root / name
folder.mkdir(parents=True)
(folder / 'SKILL.md').write_text(
f'---\nname: {name}\ndescription: Language style\n---\n' + self.body + name,
encoding='utf-8')
config = base / 'skills_config.yml'
config.write_text(json.dumps({
'enabled': True, 'allowlist': ['language', 'result-style'],
'always': ['language'], 'scenes': {'tool_result': ['result-style']},
}), encoding='utf-8')
self.registry = skill_registry.SkillRegistry(root, config)
self.registry_patch = patch.object(skill_runtime, 'registry', self.registry)
self.registry_patch.start()
def tearDown(self):
self.registry_patch.stop()
self.tmp.cleanup()
async def test_help_keeps_personal_settings_and_unwraps_text(self):
model = AsyncMock(return_value=json.dumps({'text': '主人,可以查邮件。', 'tools': []}))
with patch.object(engine, '_call_deepseek_transport', new=model), \
patch.object(engine, 'user_persona_prompt', return_value='USER_SETTING: 称呼主人'):
result = await help_ui.chat_help(42)
self.assertEqual(result, '主人,可以查邮件。')
prompt = model.await_args.args[0]
self.assertIn(self.body, prompt)
self.assertIn('USER_SETTING: 称呼主人', prompt)
self.assertIn('help', prompt)
self.assertIsNone(skill_runtime.current_turn())
async def test_notification_and_reminder_open_own_scene(self):
scenes = []
async def model(*args, **kwargs):
scenes.append(skill_runtime.current_turn().scene)
return json.dumps({'text': '提醒内容', 'tools': []})
with patch.object(engine, '_call_deepseek_transport', new=model), \
patch.object(engine, 'user_persona_prompt', return_value='USER_SETTING'):
for kind in ('komari', 'reminder'):
event = notifications.Event(kind=kind, dedupe_key='test-' + kind,
uid=42, legacy_text='原文')
self.assertEqual(await notification_render.render_event(event, 'chat', 1), '提醒内容')
self.assertEqual(scenes, ['notification', 'reminder'])
async def test_notification_cannot_execute_business_tool_and_keeps_fallback(self):
response = json.dumps({'text': '不应发送', 'tools': [{'name': 'akaros_mail', 'args': {}}]})
with patch.object(engine, '_call_deepseek_transport', new=AsyncMock(return_value=response)), \
patch.object(engine, 'user_persona_prompt', return_value='base'):
event = notifications.Event(kind='komari', dedupe_key='readonly', uid=42, legacy_text='原始告警')
result = await notification_render.render_event(event, 'chat', 1)
self.assertIn('原始告警', result)
self.assertNotIn('不应发送', result)
async def test_private_and_proactive_sessions_cover_entire_generation(self):
seen = []
async def impl(*args, **kwargs):
seen.append(skill_runtime.current_turn().scene)
with patch.object(engine, '_resolve_delivery', return_value=None), \
patch.object(engine, '_call_model_and_reply_impl', new=impl), \
patch.object(notifications.center, 'begin_conversation'), \
patch.object(notifications.center, 'end_conversation'), \
patch.object(notifications.center, 'deliver_due', new=AsyncMock()):
await engine._call_model_and_reply(None, 101, 'hi', False)
await engine._call_model_and_reply(None, 101, 'hi', True)
self.assertEqual(seen, ['private_chat', 'proactive'])
self.assertIsNone(skill_runtime.current_turn())
async def test_group_has_group_session_not_private_persona(self):
seen = []
async def impl(*args, **kwargs):
seen.append(skill_runtime.current_turn().scene)
with patch.object(engine, '_call_group_model_and_reply_impl', new=impl):
await engine._call_group_model_and_reply(None, 201, 'hi', 42)
self.assertEqual(seen, ['group_chat'])
async def test_agent_followup_binds_result_style_without_repeating_business(self):
final = json.dumps({'message': [{'content': 'done'}], 'tools': []})
model = AsyncMock(return_value=final)
channel = SimpleNamespace(name='tg', is_admin=lambda uid: False)
store = MagicMock()
with skill_runtime.skill_session('private_chat'), \
patch.object(engine, '_call_deepseek_transport', new=model), \
patch.object(engine, 'run_tools', new=AsyncMock(return_value='read result')) as tools, \
patch.object(agent_loop, 'AgentTaskStore', return_value=store), \
patch.object(agent_loop, 'ToolExecutor'):
result = await agent_loop.run_agent_loop(engine,
{'message': [], 'tools': [{'name': 'web_search', 'args': {'query': 'x'}}]},
'system', 'user', channel, 'private', 42, 1)
self.assertEqual(result['tools'], [])
tools.assert_awaited_once()
self.assertIn(self.body + 'result-style', model.await_args.args[0])
async def test_internal_sticker_audit_suspends_skills(self):
image = Path(self.tmp.name) / 'preview.png'
image.write_bytes(b'preview')
model = AsyncMock(return_value='{"send":true}')
with skill_runtime.skill_session('private_chat'), \
patch.object(engine, '_call_deepseek_transport', new=model):
self.assertTrue(await engine._confirm_sticker({'preview_path': str(image)}, 'happy', 'hi'))
self.assertNotIn(self.body, model.await_args.args[0])
def test_usage_logger_contains_counts_not_payload(self):
payload = {'usage': {'prompt_tokens': 100, 'prompt_tokens_details': {'cached_tokens': 80}},
'choices': [{'message': {'content': 'PRIVATE_MESSAGE'}}]}
with patch('builtins.print') as output:
engine._log_model_usage('test', payload)
text = output.call_args.args[0]
self.assertIn('cached_tokens', text)
self.assertNotIn('PRIVATE_MESSAGE', text)
if __name__ == '__main__':
unittest.main()
+283
View File
@@ -0,0 +1,283 @@
"""Standalone tests: imports only the registry, never production bot modules."""
import dataclasses
import importlib.util
import os
from pathlib import Path
import sys
import tempfile
import unittest
from concurrent.futures import ThreadPoolExecutor
import yaml
_MODULE_PATH = Path(__file__).resolve().parents[1] / "bot-utility" / "skill_registry.py"
_SPEC = importlib.util.spec_from_file_location("_isolated_skill_registry", _MODULE_PATH)
skills = importlib.util.module_from_spec(_SPEC)
sys.modules[_SPEC.name] = skills
_SPEC.loader.exec_module(skills)
class SkillRegistryTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.base = Path(self.tmp.name)
self.root = self.base / "skills"
self.root.mkdir()
self.config = self.base / "skills_config.yml"
self.registry = skills.SkillRegistry(self.root, self.config)
def configure(self, **kwargs):
config = {"enabled": True, "allowlist": [], "always": [], "scenes": {}}
config.update(kwargs)
self.config.write_text(yaml.safe_dump(config), encoding="utf-8")
def skill(self, name="alpha", body="Pinned instructions.\n", references=None, extra=""):
directory = self.root / name
directory.mkdir(exist_ok=True)
(directory / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: Description for {name}\n{extra}---\n{body}",
encoding="utf-8")
for path, content in (references or {}).items():
target = directory / "references" / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return directory
def error(self, code, fn, *args):
with self.assertRaises(skills.SkillError) as caught:
fn(*args)
self.assertEqual(caught.exception.code, code)
self.assertNotIn(str(self.base), str(caught.exception))
def test_missing_empty_and_default_config(self):
for text in (None, "", "# empty\n", "{}\n"):
if text is not None:
self.config.write_text(text, encoding="utf-8")
snapshot = self.registry.snapshot()
self.assertTrue(snapshot.enabled)
self.assertEqual(dict(snapshot.records), {})
self.assertEqual(snapshot.catalog_text(), "")
self.assertEqual(snapshot.bound_names("private_chat"), ())
self.assertEqual(snapshot.limits["max_tokens"], 12000)
self.assertEqual(skills.registry.root, _MODULE_PATH.parent / "skills")
self.assertEqual(skills.registry.config_path, _MODULE_PATH.parent / "skills_config.yml")
def test_catalog_allowlist_bindings_and_metadata(self):
self.skill("alpha", references={"nested/info.md": "Secret ref"},
extra="metadata:\n author: Tester\nallowed-tools: Bash Read\n")
self.skill("beta")
self.skill("excluded")
self.configure(allowlist=["beta", "alpha"], always=["alpha"],
scenes={"notification": ["beta", "alpha"], "reminder": ["alpha"],
"private_chat": ["beta"]})
snapshot = self.registry.snapshot()
self.assertEqual(tuple(snapshot.records), ("alpha", "beta"))
self.assertEqual(snapshot.catalog_text(), "alpha: Description for alpha\nbeta: Description for beta")
self.assertNotIn("Secret ref", snapshot.catalog_text())
self.assertEqual(snapshot.bound_names("private_chat"), ("alpha", "beta"))
self.assertEqual(snapshot.bound_names("reminder"), ("alpha", "beta"))
self.assertEqual(snapshot.scenes["reminder"], ("beta", "alpha"))
self.assertEqual(snapshot.records["alpha"].allowed_tools, ("Bash", "Read"))
self.assertEqual(snapshot.records["alpha"].metadata["author"], "Tester")
self.assertEqual(snapshot.read("alpha", "references/nested/info.md"), "Secret ref")
self.error("unknown_skill", snapshot.load, "excluded")
def test_snapshot_pinning_immutability_and_hash(self):
directory = self.skill(references={"info.txt": "old"})
self.configure(allowlist=["alpha"])
first = self.registry.snapshot()
with self.assertRaises(TypeError):
first.records["new"] = first.records["alpha"]
with self.assertRaises(TypeError):
first.records["alpha"].references["references/info.txt"] = "new"
with self.assertRaises(TypeError):
first.limits["max_tokens"] = 3
with self.assertRaises(dataclasses.FrozenInstanceError):
first.records["alpha"].body = "new"
os.utime(directory / "SKILL.md", (1, 1))
self.assertIs(first, self.registry.snapshot())
other = skills.SkillRegistry(self.root, self.config).snapshot()
self.assertEqual(first.fingerprint, other.fingerprint)
self.skill(body="New body", references={"info.txt": "new"})
second = self.registry.snapshot()
self.assertNotEqual(first.fingerprint, second.fingerprint)
self.assertEqual(first.load("alpha"), "Pinned instructions.\n")
self.assertEqual(first.read("alpha", "references/info.txt"), "old")
self.assertEqual(second.load("alpha"), "New body")
self.assertEqual(second.read("alpha", "references/info.txt"), "new")
self.configure(allowlist=["alpha"], limits={"max_tokens": 100})
self.assertNotEqual(second.fingerprint, self.registry.snapshot().fingerprint)
def test_invalid_config_keeps_last_good_and_disable_wins(self):
self.skill()
self.configure(allowlist=["alpha"], always=["alpha"])
good = self.registry.snapshot()
for text in ("allowlist: [", "enabled: true\nenabled: false\n", "[]", "enabled: yesplease",
"allowlist: [alpha, alpha]", "allowlist: [../alpha]", "always: [alpha]",
"scenes: {unknown: []}", "limits: {max_rounds: true}",
"allowlist: &a [alpha]\nalways: *a", "!!python/object:os.system {}",
"unexpected: value"):
with self.subTest(text=text):
self.config.write_text(text, encoding="utf-8")
self.assertIs(self.registry.snapshot(), good)
self.config.write_text("enabled: false\nalways: [missing]\nlimits: broken\n", encoding="utf-8")
disabled = self.registry.snapshot()
self.assertFalse(disabled.enabled)
self.assertEqual(disabled.catalog_text(), "")
self.assertEqual(disabled.bound_names("help"), ())
self.assertFalse(disabled.records)
self.error("disabled", disabled.load, "alpha")
self.error("disabled", disabled.read, "alpha", "references/info.md")
self.configure(allowlist=["alpha"])
self.assertTrue(self.registry.snapshot().enabled)
def test_invalid_first_config_is_fail_closed(self):
self.config.write_text("allowlist: [", encoding="utf-8")
self.assertFalse(self.registry.snapshot().enabled)
def test_limit_clamping_and_types(self):
self.configure(limits={"body_bytes": -4, "reference_bytes": 10**9,
"max_skills": 0, "max_rounds": 10**9, "max_tokens": -10})
result = self.registry.snapshot()
self.assertEqual(dict(result.limits), {"body_bytes": 1, "reference_bytes": 65536,
"max_skills": 1, "max_rounds": 8, "max_tokens": 1})
for limits in ({"max_tokens": "100"}, {"unknown": 1}, [], None):
self.configure(limits=limits)
self.assertIs(self.registry.snapshot(), result)
def test_invalid_skills_quarantined_independently(self):
self.skill("valid")
bad = self.skill("invalid")
self.configure(allowlist=["valid", "invalid"])
for document in ("no frontmatter", "---\nname: invalid\ndescription: [a]\n---\nbody",
"---\nname: valid\ndescription: duplicate name\n---\nbody",
"---\nname: invalid\nname: invalid\ndescription: desc\n---\nbody",
"---\nname: invalid\ndescription: ''\n---\nbody",
"---\nname: invalid\ndescription: " + "x" * 1025 + "\n---\nbody",
"---\nname: invalid\ndescription: hi\nmetadata: {x: []}\n---\nbody"):
with self.subTest(document=document):
(bad / "SKILL.md").write_text(document, encoding="utf-8")
with self.assertLogs(skills.__name__, level="WARNING") as logs:
snapshot = self.registry.snapshot()
self.assertEqual(tuple(snapshot.records), ("valid",))
self.assertTrue(any("quarantined" in line for line in logs.output))
self.assertTrue(all(str(self.base) not in line for line in logs.output))
def test_bound_missing_or_quarantined_record_retains_last_good(self):
directory = self.skill()
self.configure(allowlist=["alpha"], always=["alpha"])
good = self.registry.snapshot()
(directory / "SKILL.md").write_text("broken", encoding="utf-8")
self.assertIs(self.registry.snapshot(), good)
self.configure(allowlist=["missing"], scenes={"help": ["missing"]})
self.assertIs(self.registry.snapshot(), good)
def test_name_validation(self):
for name in ("Upper", "two--parts", "-first", "last-", "with_space", "x" * 65, "../escape"):
self.configure(allowlist=[name])
self.assertFalse(self.registry.snapshot().enabled)
self.skill("abc-123")
self.configure(allowlist=["abc-123"])
self.assertIn("abc-123", self.registry.snapshot().records)
def test_safe_reference_errors_and_no_script_exposure(self):
self.skill(references={"note.md": "note", "script.py": "raise RuntimeError()"})
self.configure(allowlist=["alpha"])
snapshot = self.registry.snapshot()
self.assertEqual(tuple(snapshot.records["alpha"].references), ("references/note.md",))
for path in ("../SKILL.md", "/etc/passwd", "references/../SKILL.md", "references//note.md",
"references/./note.md", "references\\note.md", "references/script.py", "note.md",
"references/\x00.md", "references/dir/", 3):
self.error("invalid_path", snapshot.read, "alpha", path)
self.error("unknown_reference", snapshot.read, "alpha", "references/missing.md")
self.error("unknown_skill", snapshot.load, "../../secret")
def test_binary_oversized_body_and_references(self):
directory = self.skill()
self.configure(allowlist=["alpha"], limits={"body_bytes": 5, "reference_bytes": 5})
self.assertNotIn("alpha", self.registry.snapshot().records)
self.skill(body="短文") # six UTF-8 bytes, not two
self.assertNotIn("alpha", self.registry.snapshot().records)
self.skill(body="ok", references={"big.txt": "123456"})
self.assertNotIn("alpha", self.registry.snapshot().records)
ref = directory / "references" / "big.txt"
for data in (b"\xff", b"\x00", b"hi\x01", b"\x7f"):
ref.write_bytes(data)
self.assertNotIn("alpha", self.registry.snapshot().records)
ref.write_text("ok", encoding="utf-8")
self.assertIn("alpha", self.registry.snapshot().records)
(directory / "SKILL.md").write_bytes(b"\x00")
self.assertNotIn("alpha", self.registry.snapshot().records)
def test_symlinks_and_outside_config_are_rejected(self):
outside = self.base / "outside"
outside.mkdir()
(outside / "SKILL.md").write_text("---\nname: escape\ndescription: desc\n---\nbody")
(self.root / "escape").symlink_to(outside, target_is_directory=True)
self.configure(allowlist=["escape"])
self.assertFalse(self.registry.snapshot().records)
directory = self.skill()
refs = directory / "references"
refs.mkdir()
(refs / "cycle").symlink_to(refs, target_is_directory=True)
self.configure(allowlist=["alpha"])
self.assertFalse(self.registry.snapshot().records)
(refs / "cycle").unlink()
(refs / "outside.md").symlink_to(outside / "SKILL.md")
self.assertFalse(self.registry.snapshot().records)
(refs / "outside.md").unlink()
good = self.registry.snapshot()
original = self.base / "original.yml"
original.write_text("enabled: false")
self.config.unlink()
self.config.symlink_to(original)
self.assertIs(self.registry.snapshot(), good)
# A config outside root.parent is rejected even if it is a regular file.
nested = outside / "nested" / "skills"
nested.mkdir(parents=True)
other = skills.SkillRegistry(nested, original)
self.assertFalse(other.snapshot().enabled)
def test_symlinked_skill_document_and_config_parent(self):
directory = self.skill()
doc = directory / "SKILL.md"
target = self.base / "document.md"
target.write_text(doc.read_text(), encoding="utf-8")
doc.unlink()
doc.symlink_to(target)
self.configure(allowlist=["alpha"])
self.assertFalse(self.registry.snapshot().records)
link = self.base / "link"
link.symlink_to(self.base, target_is_directory=True)
registry = skills.SkillRegistry(link / "skills", link / "skills_config.yml")
self.assertFalse(registry.snapshot().enabled)
def test_reference_only_change_changes_hash(self):
directory = self.skill(references={"info.md": "before"})
self.configure(allowlist=["alpha"])
before = self.registry.snapshot()
(directory / "references" / "info.md").write_text("after")
after = self.registry.snapshot()
self.assertNotEqual(before.fingerprint, after.fingerprint)
self.assertEqual(before.read("alpha", "references/info.md"), "before")
def test_config_order_and_file_timestamps_do_not_affect_hash(self):
self.skill("alpha")
self.skill("beta")
self.configure(allowlist=["alpha", "beta"])
before = self.registry.snapshot()
self.configure(allowlist=["beta", "alpha"])
self.assertIs(before, self.registry.snapshot())
def test_concurrent_snapshot_reads(self):
self.skill()
self.configure(allowlist=["alpha"])
with ThreadPoolExecutor(max_workers=6) as pool:
snapshots = list(pool.map(lambda _: self.registry.snapshot(), range(30)))
self.assertTrue(all(s is snapshots[0] for s in snapshots))
self.assertEqual(snapshots[0].load("alpha"), "Pinned instructions.\n")
if __name__ == "__main__":
unittest.main()
+437
View File
@@ -0,0 +1,437 @@
"""Transport-only skill runtime tests: no bot imports, filesystem, or network."""
import asyncio
import json
from pathlib import Path
import sys
import unittest
from unittest.mock import AsyncMock, Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "bot-utility"))
import skill_runtime as runtime
class Snapshot:
def __init__(self, *, enabled=True, records=None, always=(), scenes=None, limits=None):
self.enabled = enabled
self.records = records if records is not None else {
"alpha": {"name": "alpha", "description": "Alpha guidance", "body": "EXACT alpha body\n第二行", "references": {"references/note.md": "EXACT reference\n不要截断"}},
"beta": {"name": "beta", "description": "Beta guidance", "body": "EXACT beta body", "references": {}},
"gamma": {"name": "gamma", "description": "Gamma guidance", "body": "EXACT gamma body", "references": {}},
"delta": {"name": "delta", "description": "Delta guidance", "body": "EXACT delta body", "references": {}},
}
self.always = always
self.scenes = scenes or {}
self.limits = {"max_rounds": 2, "max_skills": 3, "max_tokens": 12000, **(limits or {})}
self.fingerprint = "fake-pinned-fingerprint"
self.loads = []
self.reads = []
def catalog_text(self):
return "\n".join(name + ": " + self.records[name]["description"] for name in sorted(self.records))
def bound_names(self, scene):
bindings = self.scenes.get(scene, ())
if scene == "reminder":
bindings = self.scenes.get("notification", ()) + bindings
return tuple(dict.fromkeys(self.always + bindings))
def load(self, name):
self.loads.append(name)
return self.records[name]["body"]
def read(self, name, path):
self.reads.append((name, path))
return self.records[name]["references"][path]
def tool(action="load", **params):
return {"name": "skill", "args": {"action": action, "params": params}}
def chat(*tools, content="final"):
return json.dumps({"message": [{"content": content}], "tools": list(tools)}, ensure_ascii=False)
def text(*tools, content="final text"):
return json.dumps({"text": content, "tools": list(tools)}, ensure_ascii=False)
class SkillRuntimeTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.snapshot = Snapshot()
self.registry = Mock()
self.registry.snapshot.return_value = self.snapshot
self.patch = patch.object(runtime, "registry", self.registry)
self.patch.start()
self.addCleanup(self.patch.stop)
print_patch = patch.object(runtime, "print", create=True)
self.summary_print = print_patch.start()
self.addCleanup(print_patch.stop)
async def test_final_prose_reuses_pinned_styles_without_json_or_tools(self):
self.snapshot.always = ('alpha',)
self.snapshot.scenes = {'tool_result': ('beta',)}
with runtime.skill_session('private_chat') as turn:
material = runtime.loaded_style_context('tool_result')
self.assertIn('EXACT alpha body', material)
self.assertIn('EXACT beta body', material)
self.assertNotIn('[Skill output contract]', material)
self.assertNotIn('[Full skill catalog]', material)
self.assertEqual(turn.rounds, 0)
call = AsyncMock(return_value='自然回复')
with runtime.suspend_skills():
result = await runtime.generate_with_skills(call, material, 'user', reasoning=False)
self.assertEqual(result, '自然回复')
self.assertIs(runtime.current_turn(), turn)
self.registry.snapshot.assert_called_once()
self.assertEqual(runtime.loaded_style_context(), '')
async def test_no_context_exact_identity_passthrough(self):
system, user, images, answer = object(), object(), object(), object()
call = AsyncMock(return_value=answer)
result = await runtime.generate_with_skills(call, system, user, 7, presence_penalty=.7, image_infos=images, reasoning=False)
self.assertIs(result, answer)
call.assert_awaited_once_with(system, user, 7, presence_penalty=.7, image_infos=images, reasoning=False)
self.registry.snapshot.assert_not_called()
async def test_disabled_and_empty_exact_passthrough(self):
for snapshot in (Snapshot(enabled=False), Snapshot(records={})):
self.registry.snapshot.return_value = snapshot
call = AsyncMock(return_value="not JSON and unmodified")
with runtime.skill_session("help", "text"):
self.assertEqual(runtime.estimated_overhead(), 0)
self.assertEqual(await runtime.generate_with_skills(call, "system", "user"), "not JSON and unmodified")
call.assert_awaited_once_with("system", "user", 0)
async def test_mixed_plan_discarded_and_transport_kwargs_preserved(self):
business = {"name": "send_mail", "args": {"body": "PROVISIONAL SECRET"}}
final = chat({"name": "web_search", "args": {"query": "safe"}})
call = AsyncMock(side_effect=[chat(tool(name="alpha"), business, content="PROVISIONAL"), final])
images = [{"url": "image"}]
with runtime.skill_session("private_chat"):
result = await runtime.generate_with_skills(call, "ORIGINAL SYSTEM", "ORIGINAL USER", 4, image_infos=images, presence_penalty=.5, reasoning=False)
self.assertEqual(result, final)
self.assertEqual(call.await_count, 2)
second = call.await_args_list[1]
self.assertIn(self.snapshot.records["alpha"]["body"], second.args[0])
self.assertNotIn("PROVISIONAL", second.args[0])
self.assertIn("ORIGINAL SYSTEM", second.args[0])
self.assertEqual(second.args[1:], ("ORIGINAL USER", 4))
self.assertIs(second.kwargs["image_infos"], images)
self.assertEqual(second.kwargs["presence_penalty"], .5)
self.assertTrue(second.kwargs["reasoning"])
async def test_load_then_read_exact_reference_and_final_disabled(self):
call = AsyncMock(side_effect=[chat(tool(name="alpha")), chat(tool("read", name="alpha", path="references/note.md")), chat()])
with runtime.skill_session("private_chat") as turn:
self.assertEqual(await runtime.generate_with_skills(call, "S", "U"), chat())
self.assertEqual(turn.rounds, 2)
prompt = call.await_args.args[0]
self.assertIn(self.snapshot.records["alpha"]["body"], prompt)
self.assertIn(self.snapshot.records["alpha"]["references"]["references/note.md"], prompt)
self.assertIn("skill tools disabled", prompt)
self.assertEqual(self.snapshot.reads, [("alpha", "references/note.md")])
async def test_read_requires_loaded_skill_and_invalid_path_safe(self):
call = AsyncMock(side_effect=[chat(tool("read", name="alpha", path="references/note.md")), chat(tool(name="alpha"), tool("read", name="alpha", path="../../secrets")), chat()])
with runtime.skill_session("private_chat"):
await runtime.generate_with_skills(call, "S", "U")
prompt = call.await_args.args[0]
self.assertIn("skill_not_loaded", prompt)
self.assertIn("invalid_reference", prompt)
self.assertNotIn("../../secrets", prompt)
self.assertEqual(len(self.snapshot.reads), 1)
async def test_duplicate_material_not_injected_twice(self):
self.snapshot.always = ("alpha",)
call = AsyncMock(side_effect=[chat(tool(name="alpha"), tool("read", name="alpha", path="references/note.md"), tool("read", name="alpha", path="references/note.md")), chat()])
with runtime.skill_session("private_chat"):
await runtime.generate_with_skills(call, "S", "U")
await runtime.generate_with_skills(AsyncMock(return_value=chat()), "S", "U", reasoning=True)
self.assertEqual(self.snapshot.loads, ["alpha"])
self.assertEqual(len(self.snapshot.reads), 1)
prompt = call.await_args.args[0]
self.assertEqual(prompt.count("EXACT alpha body"), 1)
self.assertEqual(prompt.count("EXACT reference"), 1)
async def test_repeat_read_loop_fails_closed_after_one_final_call(self):
call = AsyncMock(return_value=chat(tool(name="alpha"), {"name": "send_mail"}))
with runtime.skill_session("private_chat") as turn:
self.assertIsNone(await runtime.generate_with_skills(call, "S", "U"))
self.assertTrue(turn.failed)
self.assertIsNone(await runtime.generate_with_skills(call, "S", "U"))
self.assertEqual(call.await_count, 3)
self.assertEqual(self.snapshot.loads, ["alpha"])
self.assertTrue(all(item.kwargs["reasoning"] for item in call.await_args_list[1:]))
async def test_skill_count_includes_scene_and_always_bindings(self):
self.snapshot.always = ("alpha",)
self.snapshot.scenes = {"private_chat": ("beta", "gamma")}
call = AsyncMock(side_effect=[chat(tool(name="delta")), chat()])
with runtime.skill_session("private_chat") as turn:
await runtime.generate_with_skills(call, "S", "U")
self.assertEqual(set(turn.loaded), {"alpha", "beta", "gamma"})
self.assertIn("skill_budget", call.await_args.args[0])
self.assertNotIn("EXACT delta body", call.await_args.args[0])
async def test_budget_rejects_whole_body_not_truncated(self):
huge = "大" * 12500
self.snapshot.records["alpha"]["body"] = huge
call = AsyncMock(side_effect=[chat(tool(name="alpha")), chat()])
with runtime.skill_session("private_chat") as turn:
await runtime.generate_with_skills(call, "S", "U")
self.assertNotIn("alpha", turn.loaded)
prompt = call.await_args.args[0]
self.assertIn('"reason": "token_budget"', prompt)
self.assertNotIn("大", prompt)
self.assertGreater(runtime.estimate_tokens("中" * 10), runtime.estimate_tokens("a" * 10))
async def test_reference_budget_rejects_whole_reference(self):
self.snapshot.always = ("alpha",)
self.snapshot.records["alpha"]["references"]["references/huge.md"] = "巨" * 12000
call = AsyncMock(side_effect=[chat(tool("read", name="alpha", path="references/huge.md")), chat()])
with runtime.skill_session("private_chat") as turn:
await runtime.generate_with_skills(call, "S", "U")
self.assertFalse(turn.references)
self.assertIn("token_budget", call.await_args.args[0])
self.assertNotIn("巨", call.await_args.args[0])
async def test_text_envelope_after_original_and_unwrap(self):
call = AsyncMock(side_effect=[text(tool(name="alpha"), content="NEVER SEND"), text(content="最终正文")])
original = "只输出正文,不要JSON"
with runtime.skill_session("help", "text"):
self.assertEqual(await runtime.generate_with_skills(call, original, "U"), "最终正文")
prompt = call.await_args.args[0]
self.assertLess(prompt.index(original), prompt.index("Internal output contract"))
self.assertNotIn("NEVER SEND", prompt)
async def test_text_business_tools_rejected_including_mixed(self):
for tools in ([{"name": "mail"}], [tool(name="alpha"), {"name": "mail"}]):
call = AsyncMock(return_value=text(*tools))
with runtime.skill_session("notification", "text"):
self.assertIsNone(await runtime.generate_with_skills(call, "S", "U"))
self.assertEqual(call.await_count, 1)
self.assertEqual(self.snapshot.loads, [])
async def test_malformed_envelopes_bounded_no_raw_leak(self):
for raw in ("PRIVATE RAW TEXT", '{"message":"not a list"}', '{"message":[],"tools":"bad"}'):
call = AsyncMock(return_value=raw)
with runtime.skill_session("private_chat"):
self.assertIsNone(await runtime.generate_with_skills(call, "S", "U"))
self.assertEqual(call.await_count, 3)
self.assertNotIn(raw, call.await_args.args[0])
self.assertIn("invalid_envelope", call.await_args.args[0])
async def test_none_immediate_no_extra_api_calls(self):
for responses in ([None], [chat(tool(name="alpha")), None]):
call = AsyncMock(side_effect=responses)
with runtime.skill_session("private_chat"):
self.assertIsNone(await runtime.generate_with_skills(call, "S", "U"))
self.assertEqual(call.await_count, len(responses))
async def test_final_chat_raw_identity_and_missing_tools(self):
final = '```json\n{"message":[{"content":"ok"}],"memory":["keep"],"need_reasoning":true}\n```'
with runtime.skill_session("private_chat"):
self.assertIs(await runtime.generate_with_skills(AsyncMock(return_value=final), "S", "U"), final)
async def test_invalid_skill_requests_safe_observations(self):
bad = [tool("write", name="alpha", path="PRIVATE-PATH"), {"name": "skill", "args": []}, tool(name="PRIVATE-UNKNOWN"), tool("load", name=["alpha"])]
call = AsyncMock(side_effect=[chat(*bad), chat()])
with runtime.skill_session("private_chat"):
await runtime.generate_with_skills(call, "S", "U")
prompt = call.await_args.args[0]
self.assertIn("invalid_request", prompt)
self.assertIn("unknown_skill", prompt)
self.assertNotIn("PRIVATE-", prompt)
async def test_list_uses_full_deterministic_catalog(self):
call = AsyncMock(side_effect=[chat(tool("list")), chat()])
with runtime.skill_session("private_chat"):
await runtime.generate_with_skills(call, "S", "U")
prompts = [item.args[0] for item in call.await_args_list]
for prompt in prompts:
self.assertEqual(prompt.count(self.snapshot.catalog_text()), 1)
self.assertEqual(prompts[0].split("S\n\n")[0], prompts[1].split("S\n\n")[0])
async def test_pinned_snapshot_followups_and_nested_new_sessions(self):
self.snapshot.always = ("alpha",)
self.snapshot.scenes = {"tool_result": ("beta",)}
original = self.snapshot
replacement = Snapshot(always=("gamma",))
with runtime.skill_session("private_chat") as outer:
self.registry.snapshot.return_value = replacement
with runtime.skill_scene("tool_result") as same:
self.assertIs(same, outer)
self.assertEqual(set(same.loaded), {"alpha", "beta"})
self.assertIs(same.snapshot, original)
with runtime.skill_session("help", "text") as inner:
self.assertIsNot(inner, outer)
self.assertIs(inner.snapshot, replacement)
self.assertEqual(set(inner.loaded), {"gamma"})
self.assertIs(runtime.current_turn(), outer)
call = AsyncMock(return_value=chat())
await runtime.generate_with_skills(call, "S", "U")
self.assertIn('"scene": "private_chat"', call.await_args.args[0])
self.assertIn("EXACT beta body", call.await_args.args[0])
self.assertIsNone(runtime.current_turn())
self.assertEqual(self.registry.snapshot.call_count, 2)
async def test_parallel_session_isolation(self):
both_entered = asyncio.Event()
entered = []
async def worker(name):
with runtime.skill_session("private_chat") as turn:
turn.load(name)
entered.append(name)
if len(entered) == 2:
both_entered.set()
await both_entered.wait()
self.assertIs(runtime.current_turn(), turn)
call = AsyncMock(return_value=chat())
await runtime.generate_with_skills(call, "S", "U")
return set(turn.loaded), call.await_args.args[0]
first, second = await asyncio.gather(worker("alpha"), worker("beta"))
self.assertEqual(first[0], {"alpha"})
self.assertEqual(second[0], {"beta"})
self.assertNotIn("EXACT beta body", first[1])
self.assertNotIn("EXACT alpha body", second[1])
self.assertIsNone(runtime.current_turn())
async def test_no_implicit_session_suspend_and_exception_restoration(self):
with runtime.skill_scene("tool_result") as absent:
self.assertIsNone(absent)
self.assertEqual(runtime.estimated_overhead(), 0)
self.registry.snapshot.assert_not_called()
with runtime.skill_session("private_chat") as turn:
self.assertEqual(runtime.estimated_overhead(), 12000)
with self.assertRaisesRegex(RuntimeError, "expected"):
with runtime.suspend_skills():
self.assertIsNone(runtime.current_turn())
call = AsyncMock(return_value="plain")
self.assertEqual(await runtime.generate_with_skills(call, "S", "U"), "plain")
call.assert_awaited_once_with("S", "U", 0)
raise RuntimeError("expected")
self.assertIs(runtime.current_turn(), turn)
self.assertEqual(runtime.estimated_overhead("private_chat"), 12000)
async def test_followup_reuses_round_budget_and_forces_reasoning(self):
first = AsyncMock(side_effect=[chat(tool(name="alpha")), chat()])
followup = AsyncMock(side_effect=[chat(tool(name="beta")), chat()])
with runtime.skill_session("private_chat") as turn:
await runtime.generate_with_skills(first, "S", "U")
with runtime.skill_scene("tool_result"):
await runtime.generate_with_skills(followup, "S2", "U2", reasoning=False)
self.assertEqual(turn.rounds, 2)
self.assertTrue(followup.await_args_list[0].kwargs["reasoning"])
self.assertEqual(followup.await_args.args[0].count("EXACT alpha body"), 1)
async def test_always_stable_prefix_scene_and_original_not_truncated(self):
self.snapshot.always = ("alpha",)
self.snapshot.scenes = {"tool_result": ("beta",)}
original = "ORIGINAL-START" + "历史" * 20000 + "ORIGINAL-END"
call = AsyncMock(return_value=chat())
with runtime.skill_session("private_chat"):
await runtime.generate_with_skills(call, original, "U")
with runtime.skill_scene("tool_result"):
await runtime.generate_with_skills(call, original, "U")
first, second = [item.args[0] for item in call.await_args_list]
self.assertIn(original, first)
self.assertIn(original, second)
self.assertEqual(first.split("[Skill source")[0], second.split("[Skill source")[0])
self.assertLess(second.index("EXACT alpha body"), second.index(original))
self.assertLess(second.index("EXACT beta body"), second.index(original))
async def test_reminder_inherits_notification_binding(self):
self.snapshot.scenes = {"notification": ("alpha",)}
with runtime.skill_session("reminder", "text") as turn:
self.assertEqual(set(turn.loaded), {"alpha"})
async def test_tiny_budget_fails_before_transport(self):
self.snapshot.limits["max_tokens"] = 10
call = AsyncMock(return_value=chat())
with runtime.skill_session("private_chat") as turn:
self.assertIsNone(await runtime.generate_with_skills(call, "S", "U"))
self.assertEqual(turn.observations[-1]["reason"], "token_budget")
call.assert_not_awaited()
async def test_final_skill_disabled_allows_final_business_plan(self):
final = chat({"name": "mail"})
call = AsyncMock(side_effect=[chat(tool(name="alpha")), chat(tool(name="beta")), final])
with runtime.skill_session("private_chat"):
self.assertEqual(await runtime.generate_with_skills(call, "S", "U"), final)
self.assertEqual(call.await_count, 3)
self.assertIn("skill tools disabled", call.await_args.args[0])
async def test_text_malformed_is_not_leaked_and_can_repair(self):
call = AsyncMock(side_effect=["PRIVATE RAW ANSWER", text(content="repaired")])
with runtime.skill_session("help", "text"):
self.assertEqual(await runtime.generate_with_skills(call, "S", "U"), "repaired")
self.assertNotIn("PRIVATE RAW ANSWER", call.await_args.args[0])
self.assertTrue(call.await_args.kwargs["reasoning"])
async def test_load_exposes_reference_names_but_not_unread_source(self):
self.snapshot.records["alpha"]["references"]["../private.md"] = "hidden"
call = AsyncMock(side_effect=[chat(tool(name="alpha")), chat()])
with runtime.skill_session("private_chat"):
await runtime.generate_with_skills(call, "S", "U")
prompt = call.await_args.args[0]
self.assertIn('"available_references": ["references/note.md"]', prompt)
self.assertNotIn("EXACT reference", prompt)
self.assertNotIn("../private.md", prompt)
self.assertEqual(self.snapshot.reads, [])
async def test_reserved_budget_never_forwarded_even_disabled(self):
call = AsyncMock(return_value="plain")
self.assertEqual(await runtime.generate_with_skills(call, "S", "U", _prompt_token_budget=1), "plain")
call.assert_awaited_once_with("S", "U", 0)
self.registry.snapshot.assert_not_called()
async def test_total_text_budget_rejects_without_cutting_or_transport(self):
call = AsyncMock(return_value=chat())
with runtime.skill_session("private_chat") as turn:
result = await runtime.generate_with_skills(call, "S" * 4000, "中" * 1000, _prompt_token_budget=1000)
self.assertIsNone(result)
self.assertEqual(turn.observations[-1]["reason"], "total_token_budget")
call.assert_not_awaited()
async def test_total_text_budget_uses_engine_compatible_estimate(self):
call = AsyncMock(return_value=chat())
original = "a" * 20000
with runtime.skill_session("private_chat"):
result = await runtime.generate_with_skills(call, original, "U", _prompt_token_budget=6000)
self.assertEqual(result, chat())
self.assertNotIn("_prompt_token_budget", call.await_args.kwargs)
self.assertIn(original, call.await_args.args[0])
async def test_small_valid_budget_can_still_load_short_source(self):
self.snapshot.limits["max_tokens"] = 1200
call = AsyncMock(side_effect=[chat(tool(name="alpha")), chat()])
with runtime.skill_session("private_chat") as turn:
self.assertEqual(await runtime.generate_with_skills(call, "S", "U"), chat())
self.assertIn("alpha", turn.loaded)
async def test_summary_prints_safe_metadata_and_bound_priority(self):
self.snapshot.always = ("alpha",)
with runtime.skill_session("private_chat"):
pass
summary = self.summary_print.call_args.args[0]
self.assertIn("[skills]", summary)
self.assertIn("fake-pinned-fingerprint", summary)
self.assertIn('"ids": ["alpha"]', summary)
self.assertIn('"tokens":', summary)
self.assertNotIn("EXACT alpha body", summary)
self.assertIn("personal expression preferences override skill", runtime._RULES)
async def test_logs_do_not_contain_sources_or_user_data(self):
call = AsyncMock(side_effect=[chat(tool(name="alpha"), tool(name="PRIVATE USER SECRET")), chat()])
with self.assertLogs("skill_runtime", "DEBUG") as logs:
with runtime.skill_session("private_chat"):
await runtime.generate_with_skills(call, "SECRET SYSTEM", "SECRET USER")
output = "\n".join(logs.output)
self.assertNotIn("SECRET", output)
self.assertNotIn("EXACT alpha body", output)
self.assertIn("hash=", output)
if __name__ == "__main__":
unittest.main()
+124
View File
@@ -0,0 +1,124 @@
"""Integration tests for learning and sending user-owned stickers."""
from pathlib import Path
import sys
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
import main
from chat import engine
class FakeStore:
def __init__(self, item=None):
self.item = item
self.learned = []
self.random_calls = []
def learn(self, scope, source, classification):
self.learned.append((scope, source, classification))
return {"id": "learned"}
def random_for_scene(self, scope, scene):
self.random_calls.append((scope, scene))
return self.item
class StickerLearningTests(unittest.IsolatedAsyncioTestCase):
async def test_only_marked_stickers_are_learned(self):
store = FakeStore()
images = [
{"kind": "sticker", "source_id": "s1", "original_path": "/tmp/a.webp"},
{"kind": "photo", "source_id": "s2", "original_path": "/tmp/b.jpg"},
]
parsed = {"learned_stickers": [
{"source_id": "s1", "accept": True, "scenes": ["sad"], "description": "委屈"},
{"source_id": "s2", "accept": True, "scenes": ["happy"], "description": "普通照片"},
]}
with patch.object(engine, "STICKER_STORE", store), \
patch.object(engine, "stickers_enabled", return_value=True):
engine._learn_stickers(1001, images, parsed)
self.assertEqual(len(store.learned), 1)
self.assertEqual(store.learned[0][0], engine.STICKER_GLOBAL_SCOPE)
self.assertEqual(store.learned[0][2]["scenes"], ["sad"])
async def test_source_marker_preserves_user_instruction(self):
text = engine._with_sticker_sources(
"这个适合什么时候用",
[{"kind": "sticker", "source_id": "s1"}, {"kind": "photo"}],
)
self.assertIn("source_id=s1", text)
self.assertTrue(text.endswith("这个适合什么时候用"))
async def test_random_candidate_requires_visual_confirmation(self):
with tempfile.TemporaryDirectory() as directory:
original = Path(directory, "a.webp")
preview = Path(directory, "a.png")
original.write_bytes(b"sticker")
preview.write_bytes(b"preview")
item = {"id": "a", "path": str(original), "preview_path": str(preview)}
store = FakeStore(item)
bot = SimpleNamespace(send_sticker=AsyncMock())
channel = SimpleNamespace(bot=bot, _to_raw_target=lambda raw: raw)
with patch.object(engine, "STICKER_STORE", store), \
patch.object(engine, "stickers_enabled", return_value=True), \
patch.object(engine, "_resolve_delivery", return_value=(channel, 10001)), \
patch.object(engine, "_confirm_sticker", new=AsyncMock(return_value=False)):
sent = await engine._maybe_send_sticker(channel, 1, "sad", "context")
self.assertFalse(sent)
self.assertEqual(store.random_calls, [(engine.STICKER_GLOBAL_SCOPE, "sad")])
bot.send_sticker.assert_not_awaited()
async def test_confirmation_uses_candidate_preview(self):
with tempfile.TemporaryDirectory() as directory:
preview = Path(directory, "preview.png")
preview.write_bytes(b"preview")
model = AsyncMock(return_value='{"send":true}')
with patch.object(engine, "call_deepseek", new=model):
approved = await engine._confirm_sticker(
{"preview_path": str(preview), "description": "轻微委屈"},
"sad", "用户有点失落",
)
self.assertTrue(approved)
self.assertEqual(model.await_args.kwargs["image_infos"][0]["url"], str(preview))
class TelegramStickerInputTests(unittest.IsolatedAsyncioTestCase):
async def test_tg_sticker_is_downloaded_and_forwarded_as_sticker(self):
sticker = SimpleNamespace(
is_animated=False,
is_video=False,
file_unique_id="unique-1",
emoji="🙂",
set_name="set-1",
)
message = SimpleNamespace(
from_user=SimpleNamespace(id=123, is_bot=False),
chat=SimpleNamespace(id=123, type="private"),
text=None,
caption=None,
photo=[],
sticker=sticker,
entities=[],
)
async def download(obj, destination):
Path(destination).write_bytes(b"sticker")
bot = SimpleNamespace(download=download, id=999)
dispatch = AsyncMock()
with patch.object(main, "handle_message", new=dispatch):
await main.make_tg_handler(SimpleNamespace(tg_username="bot"))(message, bot)
images = dispatch.await_args.args[5]
self.assertEqual(len(images), 1)
self.assertEqual(images[0]["kind"], "sticker")
self.assertEqual(images[0]["source_id"], "s1")
self.assertEqual(images[0]["file_unique_id"], "unique-1")
self.assertEqual(images[0]["format"], "static")
self.assertTrue(Path(images[0]["original_path"]).exists())
Path(images[0]["original_path"]).unlink()
if __name__ == "__main__":
unittest.main()
+260
View File
@@ -0,0 +1,260 @@
"""Offline unit tests for the scope-isolated sticker store."""
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
# Locally this file and sticker_store.py share a directory. In deployment the
# test lives in backend/tests and the module in bot-utility/chat.
TEST_DIR = Path(__file__).resolve().parent
BACKEND = TEST_DIR.parent
sys.path[:0] = [
str(TEST_DIR),
str(BACKEND / "bot-utility" / "chat"),
str(BACKEND / "bot-utility"),
]
from sticker_store import SCENES, StickerStore, scene_prompt # noqa: E402
class StickerStoreTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.base = Path(self.temporary.name)
self.store = StickerStore(self.base / "store")
self.assets = self.base / "assets"
self.assets.mkdir()
def tearDown(self):
self.temporary.cleanup()
def make_source(self, name="one.webp", unique_id="unique-1", body=b"sticker"):
original = self.assets / name
original.write_bytes(body)
preview = self.assets / f"{original.stem}-preview.png"
preview.write_bytes(b"preview-" + body)
return {
"original_path": str(original),
"url": str(preview),
"file_unique_id": unique_id,
"emoji": "🙂",
"set_name": "test-set",
"source_uid": "sender-1",
}
def test_scene_prompt_lists_all_supported_scenes(self):
prompt = scene_prompt()
for scene, chinese_description in SCENES.items():
self.assertIn(scene, prompt)
self.assertIn(chinese_description, prompt)
def test_learn_saves_object_preview_index_and_metadata(self):
source = self.make_source()
record = self.store.learn(
"user-1",
source,
{"accept": True, "scenes": ["happy", "daily_cute"], "description": "开心挥手"},
)
self.assertIsNotNone(record)
self.assertEqual(record["scenes"], ["happy", "daily_cute"])
self.assertEqual(record["description"], "开心挥手")
self.assertEqual(record["emoji"], "🙂")
self.assertEqual(record["set_name"], "test-set")
self.assertEqual(record["source_uid"], "sender-1")
self.assertEqual(record["file_unique_id"], "unique-1")
self.assertEqual(record["format"], "webp")
self.assertTrue(record["created_at"])
self.assertEqual(len(record["id"]), 32)
object_path = Path(record["path"])
preview_path = Path(record["preview_path"])
self.assertTrue(object_path.is_absolute())
self.assertTrue(preview_path.is_absolute())
self.assertEqual(object_path.read_bytes(), b"sticker")
self.assertEqual(preview_path.read_bytes(), b"preview-sticker")
self.assertEqual(object_path.parent, self.base / "store" / "user-1" / "objects")
self.assertEqual(preview_path.parent, self.base / "store" / "user-1" / "previews")
index_path = self.base / "store" / "user-1" / "index.json"
saved_index = json.loads(index_path.read_text(encoding="utf-8"))
self.assertIn(record["id"], saved_index["stickers"])
self.assertEqual(StickerStore(self.base / "store").get("user-1", record["id"]), record)
def test_invalid_scenes_and_explicit_rejection_are_not_learned(self):
invalid_classifications = [
{"accept": False, "scenes": ["happy"]},
{"scenes": []},
{"scenes": ["not-a-scene"]},
{"scenes": ["happy", "not-a-scene"]},
{"scenes": ["happy", "sad", "angry", "thanks"]},
{"scenes": "happy"},
]
for number, classification in enumerate(invalid_classifications):
with self.subTest(classification=classification):
source = self.make_source(
name=f"invalid-{number}.webp", unique_id=f"invalid-{number}"
)
self.assertIsNone(self.store.learn("user-1", source, classification))
self.assertEqual(self.store.prompt_summary("user-1"), "")
def test_random_for_scene_chooses_only_same_scene_candidates(self):
first = self.store.learn(
"user-1",
self.make_source("first.webp", "first"),
{"scenes": ["happy"], "description": "第一张"},
)
second = self.store.learn(
"user-1",
self.make_source("second.webp", "second"),
{"scenes": ["happy", "sad"], "description": "第二张"},
)
self.store.learn(
"user-1",
self.make_source("third.webp", "third"),
{"scenes": ["thinking"], "description": "第三张"},
)
with patch("sticker_store.random.choice", side_effect=lambda items: items[0]) as choice:
selected = self.store.random_for_scene("user-1", "happy")
candidates = choice.call_args.args[0]
self.assertEqual(selected["id"], candidates[0]["id"])
self.assertEqual({item["id"] for item in candidates}, {first["id"], second["id"]})
with patch("sticker_store.random.choice", side_effect=lambda items: items[-1]) as choice:
selected = self.store.random_for_scene("user-1", "happy")
self.assertEqual(selected["id"], choice.call_args.args[0][-1]["id"])
self.assertIsNone(self.store.random_for_scene("user-1", "angry"))
def test_legacy_scopes_merge_into_shared_global_library(self):
first = self.store.learn(
1001, self.make_source("first.webp", "first"),
{"scenes": ["happy"], "description": "私聊学到"},
)
second = self.store.learn(
2002, self.make_source("second.webp", "second"),
{"scenes": ["sad"], "description": "群聊学到"},
)
migrated = self.store.migrate_all_to_global()
self.assertEqual(migrated, 2)
self.assertIsNotNone(self.store.random_for_scene("global", "happy"))
self.assertIsNotNone(self.store.random_for_scene("global", "sad"))
self.assertIn("私聊学到", self.store.prompt_summary("global"))
self.assertIn("群聊学到", self.store.prompt_summary("global"))
self.assertFalse((self.base / "store" / "1001").exists())
self.assertTrue((self.base / "store" / ".migrated" / "1001").is_dir())
self.assertEqual(self.store.migrate_all_to_global(), 0)
self.assertNotEqual(first["id"], second["id"])
def test_failed_record_prevents_scope_archive_until_retry_succeeds(self):
valid = self.store.learn(
1001, self.make_source("valid.webp", "valid"),
{"scenes": ["happy"], "description": "可迁移"},
)
broken = self.store.learn(
1001, self.make_source("broken.webp", "broken"),
{"scenes": ["sad"], "description": "源文件丢失"},
)
broken_path = Path(broken["path"])
broken_path.unlink()
self.assertEqual(self.store.migrate_all_to_global(), 1)
self.assertTrue((self.base / "store" / "1001").is_dir())
self.assertFalse((self.base / "store" / ".migrated" / "1001").exists())
self.assertIsNotNone(self.store.random_for_scene("global", "happy"))
self.assertIsNone(self.store.random_for_scene("global", "sad"))
broken_path.write_bytes(b"repaired")
self.assertEqual(self.store.migrate_all_to_global(), 2)
self.assertFalse((self.base / "store" / "1001").exists())
self.assertTrue((self.base / "store" / ".migrated" / "1001").is_dir())
self.assertIsNotNone(self.store.random_for_scene("global", "sad"))
self.assertEqual(valid["file_unique_id"], "valid")
def test_duplicate_file_unique_id_merges_without_copying(self):
first = self.store.learn(
"user-1",
self.make_source("original.webp", "same-id", b"original"),
{"scenes": ["happy"], "description": "旧描述"},
)
second = self.store.learn(
"user-1",
self.make_source("replacement.webp", "same-id", b"replacement"),
{"scenes": ["sad", "happy"], "description": "新描述"},
)
self.assertEqual(second["id"], first["id"])
self.assertEqual(second["scenes"], ["happy", "sad"])
self.assertEqual(second["description"], "新描述")
self.assertEqual(second["created_at"], first["created_at"])
self.assertEqual(Path(second["path"]).read_bytes(), b"original")
self.assertEqual(Path(second["preview_path"]).read_bytes(), b"preview-original")
self.assertEqual(len(list((self.base / "store" / "user-1" / "objects").iterdir())), 1)
self.assertEqual(len(list((self.base / "store" / "user-1" / "previews").iterdir())), 1)
def test_scopes_have_independent_inventory_and_deduplication(self):
private = self.store.learn(
"private-uid",
self.make_source(),
{"scenes": ["happy"], "description": "私聊贴纸"},
)
group = self.store.learn(
"group-id",
self.make_source("group.webp", body=b"group"),
{"scenes": ["sad"], "description": "群聊贴纸"},
)
self.assertNotEqual(private["id"], group["id"])
self.assertEqual(self.store.get("private-uid", private["id"])["description"], "私聊贴纸")
self.assertIsNone(self.store.get("private-uid", group["id"]))
self.assertIsNone(self.store.get("group-id", private["id"]))
self.assertIsNone(self.store.random_for_scene("private-uid", "sad"))
self.assertEqual(self.store.random_for_scene("group-id", "sad")["id"], group["id"])
self.assertTrue((self.base / "store" / "private-uid" / "index.json").is_file())
self.assertTrue((self.base / "store" / "group-id" / "index.json").is_file())
def test_prompt_summary_contains_only_scene_counts_and_briefs(self):
first = self.store.learn(
"user-1",
self.make_source("first.webp", "first"),
{"scenes": ["happy"], "description": "开心鼓掌"},
)
second = self.store.learn(
"user-1",
self.make_source("second.webp", "second"),
{"scenes": ["happy", "comfort"], "description": "温柔拥抱"},
)
summary = self.store.prompt_summary("user-1")
self.assertIn("happy", summary)
self.assertIn("开心、愉快或表达喜悦", summary)
self.assertIn("2 个", summary)
self.assertIn("comfort", summary)
self.assertIn("1 个", summary)
self.assertIn("开心鼓掌", summary)
self.assertIn("温柔拥抱", summary)
for forbidden in (
first["id"],
second["id"],
first["file_unique_id"],
first["path"],
first["preview_path"],
"objects/",
"previews/",
):
self.assertNotIn(forbidden, summary)
def test_scope_cannot_escape_root(self):
source = self.make_source()
for scope in ("../outside", "/tmp/outside", "a/b", "a\\b"):
with self.subTest(scope=scope), self.assertRaises(ValueError):
self.store.learn(scope, source, {"scenes": ["happy"]})
self.assertFalse((self.base / "outside").exists())
if __name__ == "__main__":
unittest.main()
+40
View File
@@ -0,0 +1,40 @@
"""Runtime sticker disable switch tests; no real Telegram delivery."""
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import AsyncMock, patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
from chat import engine
class StickerSwitchTests(unittest.IsolatedAsyncioTestCase):
def test_disabled_hides_tags_from_model(self):
with patch.object(engine, "load_setting", return_value={"stickers": False}), \
patch.object(engine.os, "listdir") as listdir:
self.assertEqual(engine.available_sticker_tags(), [])
listdir.assert_not_called()
def test_enabled_still_loads_existing_tags(self):
with tempfile.TemporaryDirectory() as directory:
Path(directory, "happy.webp").write_bytes(b"sticker")
with patch.object(engine, "load_setting", return_value={"stickers": True}), \
patch.object(engine, "STICKERS_DIR", directory):
self.assertEqual(engine.available_sticker_tags(), ["happy"])
async def test_disabled_blocks_direct_send_even_with_tag(self):
bot = type("Bot", (), {"send_sticker": AsyncMock()})()
channel = type("Channel", (), {"bot": bot})()
with patch.object(engine, "load_setting", return_value={"stickers": False}), \
patch.object(engine, "_resolve_delivery") as resolve:
await engine._maybe_send_sticker(channel, 1, "happy")
resolve.assert_not_called()
bot.send_sticker.assert_not_awaited()
if __name__ == "__main__":
unittest.main()
+86
View File
@@ -0,0 +1,86 @@
"""Per-user persona overlays must be shared by chat, help, and notifications."""
from pathlib import Path
import sys
import unittest
from unittest.mock import AsyncMock, patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
import help_ui
import notification_render
import notifications
from chat import engine
class UserPersonaTests(unittest.IsolatedAsyncioTestCase):
def test_overlay_uses_long_term_preferences_with_latest_priority(self):
rows = [
(2, "称呼用户为主人", 5, "个人设定,表达偏好", 2, 0),
(1, "用户不喜欢书面腔", 4, "表达偏好", 1, 0),
]
with patch.object(engine.memory_store, "list_facts", return_value=rows) as facts:
prompt = engine.user_persona_prompt(42)
facts.assert_called_once_with(42, limit=30)
self.assertIn("当前用户的个人设定", prompt)
self.assertIn("称呼用户为主人", prompt)
self.assertIn("最新、最明确", prompt)
self.assertTrue(prompt.startswith(engine.BASE_PROMPT))
self.assertIs(engine.user_persona_prompt(None), engine.BASE_PROMPT)
def test_private_chat_prompt_uses_same_user_overlay(self):
custom = engine.BASE_PROMPT + "\n【当前用户的个人设定】称呼用户为主人"
with patch.object(engine, "user_persona_prompt", return_value=custom) as persona, \
patch.object(engine, "load_user_status", return_value={"pause": False, "sleep": False}), \
patch.object(engine.memory_store, "get_relation", return_value=None), \
patch.object(engine.memory_store, "list_entities", return_value=[]), \
patch.object(engine.memory_store, "recall_facts", return_value=[]), \
patch.object(engine.memory_store, "migrate_legacy"), \
patch.object(engine, "get_history_within_budget", return_value=[]), \
patch.object(engine.notebook.notebook, "context_for_chat", return_value=""), \
patch.object(engine, "_sticker_prompt_summary", return_value=""):
prompt = engine.build_system_prompt(42, query="你好")
persona.assert_called_once_with(42)
self.assertTrue(prompt.startswith(custom))
async def test_notification_renderer_uses_same_user_overlay(self):
event = notifications.Event(
kind="komari", dedupe_key="persona-test", uid=42,
legacy_text="raw", payload={"server": "Ama-10", "status": "offline"},
)
custom = engine.BASE_PROMPT + "\n【当前用户的个人设定】称呼用户为主人"
with patch.object(engine, "user_persona_prompt", return_value=custom) as persona, \
patch.object(engine, "call_deepseek", new=AsyncMock(return_value="主人,Ama-10 掉线了。")) as model:
rendered = await notification_render.render_event(event, "chat", 10001)
persona.assert_called_once_with(42)
self.assertTrue(model.await_args.args[0].startswith(custom))
self.assertEqual(rendered, "主人,Ama-10 掉线了。")
async def test_help_renderer_uses_same_user_overlay(self):
custom = engine.BASE_PROMPT + "\n【当前用户的个人设定】称呼用户为主人"
with patch.object(engine, "user_persona_prompt", return_value=custom) as persona, \
patch.object(engine, "call_deepseek", new=AsyncMock(return_value="主人,可以用这些功能。")):
await help_ui.chat_help(42)
persona.assert_called_once_with(42)
def test_deterministic_notification_fallback_has_no_hardcoded_address(self):
mail = notifications.Event(
kind="mail", dedupe_key="mail-persona", uid=42, legacy_text="raw",
payload={"mid": 1, "subject": "test", "sender": "a@example.test"},
)
ginkgo = notifications.Event(
kind="ginkgo:course", dedupe_key="ginkgo-persona", uid=42, legacy_text="raw",
)
self.assertNotIn("主人", notification_render._fallback(mail))
self.assertNotIn("主人", notification_render._fallback(ginkgo))
def test_memory_instruction_preserves_expression_preferences(self):
prompt = engine.load_instruction("agent_instructions.txt")
self.assertIn("个人设定", prompt)
self.assertIn("表达偏好", prompt)
self.assertIn("importance=5", prompt)
if __name__ == "__main__":
unittest.main()
+260
View File
@@ -0,0 +1,260 @@
"""Wardrobe tests: all persistent writes are restricted to temporary databases."""
import importlib.util
import json
import os
from pathlib import Path
import sqlite3
import sys
import tempfile
import threading
import unittest
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "bot-utility"))
import wardrobe as wardrobe_module
from wardrobe import DEFAULT_DESCRIPTION, DEFAULT_ID, HELP_TEXT, WardrobeStore, format_item
def ctx(uid=1, channel="telegram", msg_type="private"):
return SimpleNamespace(user_id=uid, channel=SimpleNamespace(name=channel),
msg_type=msg_type, is_admin=True)
def context_records(text):
return [json.loads(line) for line in text.splitlines() if line.startswith('{')]
class WardrobeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.path = Path(self.temp.name) / "data" / "wardrobe.db"
self.store = WardrobeStore(self.path)
self.ctx = ctx()
def tearDown(self):
self.store.close()
self.temp.cleanup()
def test_import_and_constructor_are_lazy_and_environment_is_supported(self):
self.assertFalse(self.path.exists())
with mock.patch.dict(os.environ, {"WARDROBE_DB_PATH": str(self.path)}):
with mock.patch.object(sqlite3, "connect", side_effect=AssertionError("import IO")):
spec = importlib.util.spec_from_file_location("isolated_wardrobe", wardrobe_module.__file__)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
self.assertEqual(module.wardrobe.db_path, str(self.path))
self.assertEqual(WardrobeStore().db_path, str(self.path))
self.assertFalse(self.path.parent.exists())
with mock.patch.dict(os.environ, {}, clear=True):
self.assertEqual(WardrobeStore().db_path, str(ROOT / "bot-utility" / "data" / "wardrobe.db"))
def test_default_once_restart_and_uid_seed(self):
records = self.store.list(self.ctx)
self.assertEqual(len(records), 1)
default = records[0]
self.assertEqual(default["id"], DEFAULT_ID)
self.assertEqual(default["name"], "白色连衣裙")
self.assertEqual(default["category"], "连衣裙")
self.assertEqual(default["description"], DEFAULT_DESCRIPTION)
self.assertEqual(set(default), {"id", "uid", "name", "category", "description", "created_at", "updated_at"})
self.store.close()
self.assertEqual(self.store.list(self.ctx), records)
self.assertEqual(self.store.list(ctx(2))[0]["uid"], 2)
with sqlite3.connect(self.path) as con:
self.assertEqual(con.execute("SELECT count(*) FROM wardrobe_users").fetchone()[0], 2)
def test_default_deleted_never_reseeded_including_restart(self):
self.store.delete(self.ctx, DEFAULT_ID)
self.assertEqual(self.store.list(self.ctx), [])
self.store.close()
other = WardrobeStore(self.path)
try:
self.assertEqual(other.list(self.ctx), [])
text = other.context_for_chat(1)
self.assertIn("空;总计 0 件,展示 0 件", text)
self.assertNotIn(DEFAULT_DESCRIPTION, text)
finally:
other.close()
def test_default_update_survives_restart(self):
updated = self.store.update(self.ctx, DEFAULT_ID, name="修改后的白裙", description="新的完整描述", category="裙子")
self.store.close()
self.assertEqual(self.store.list(self.ctx), [updated])
self.assertIn("新的完整描述", self.store.context_for_chat(1))
self.assertNotIn(DEFAULT_DESCRIPTION, self.store.context_for_chat(1))
def test_concurrent_first_seed_and_duplicate_across_connections(self):
barrier = threading.Barrier(12)
def worker(index):
store = WardrobeStore(self.path)
try:
barrier.wait()
return store.add(self.ctx, "蓝衬衫", "相同描述", "衬衫")
finally:
store.close()
with ThreadPoolExecutor(max_workers=12) as pool:
records = list(pool.map(worker, range(12)))
self.assertTrue(all(record == records[0] for record in records))
self.assertEqual(len(self.store.list(self.ctx)), 2)
with sqlite3.connect(self.path) as con:
self.assertEqual(con.execute("SELECT count(*) FROM wardrobe_users WHERE uid=1").fetchone()[0], 1)
def test_shared_instance_concurrent_access(self):
with ThreadPoolExecutor(max_workers=8) as pool:
lists = list(pool.map(lambda _: self.store.list(self.ctx), range(32)))
self.assertTrue(all(records == lists[0] for records in lists))
self.assertEqual(len(lists[0]), 1)
def test_uid_isolation_and_channel_independence(self):
record = self.store.add(ctx(1, "telegram"), "私有外套", "不可泄露")
self.assertIn(record, self.store.list(ctx(1, "qq")))
self.assertNotIn(record, self.store.list(ctx(2)))
for action in (self.store.update, self.store.delete):
with self.assertRaises(ValueError):
action(ctx(2), record["id"])
self.assertIn(record, self.store.list(self.ctx))
self.assertNotIn("不可泄露", self.store.context_for_chat(2))
self.store.update(ctx(2), DEFAULT_ID, name="用户二裙子")
self.assertEqual(self.store.list(self.ctx, "白色连衣裙")[0]["name"], "白色连衣裙")
def test_nonprivate_denied_even_admin_without_database_creation(self):
for msg_type in ("group", "guild", "", None, "PRIVATE"):
context = ctx(msg_type=msg_type)
for action in (lambda: self.store.list(context), lambda: self.store.add(context, "衣服"),
lambda: self.store.update(context, DEFAULT_ID), lambda: self.store.delete(context, DEFAULT_ID)):
with self.assertRaises(PermissionError):
action()
self.assertFalse(self.path.exists())
def test_uid_validation_all_public_methods(self):
for uid in (True, False, 0, -1, "1", 1.0, None, 2**63):
for action in (lambda: self.store.list(ctx(uid)), lambda: self.store.add(ctx(uid), "衣服"),
lambda: self.store.update(ctx(uid), DEFAULT_ID), lambda: self.store.delete(ctx(uid), DEFAULT_ID),
lambda: self.store.context_for_chat(uid)):
with self.assertRaises(ValueError):
action()
self.assertFalse(self.path.exists())
def test_crud_full_info_idempotency_and_queries(self):
record = self.store.add(self.ctx, " 蓝色 外套 ", " 全棉 宽松 ", " 外套 ")
self.assertEqual(record["name"], "蓝色 外套")
self.assertEqual(record["description"], "全棉 宽松")
self.assertRegex(record["id"], r"^[0-9a-f]{12}$")
self.assertEqual(record, self.store.add(self.ctx, "蓝色 外套", "全棉 宽松", "外套"))
self.assertEqual(record, self.store.update(self.ctx, record["id"]))
self.assertEqual(record, self.store.update(self.ctx, record["id"], name=" 蓝色 外套 ", description="全棉 宽松", category="外套"))
self.assertEqual(self.store.list(self.ctx, "全棉"), [record])
self.assertEqual(self.store.list(self.ctx, "外套"), [record])
self.assertEqual(self.store.list(self.ctx, "不存在"), [])
updated = self.store.update(self.ctx, record["id"], name="新外套", description="", category="衣服")
self.assertEqual(updated["description"], "")
self.assertEqual(updated["created_at"], record["created_at"])
self.assertEqual(json.loads(format_item(updated)), updated)
self.assertEqual(self.store.delete(self.ctx, record["id"]), updated)
with self.assertRaises(ValueError):
self.store.delete(self.ctx, record["id"])
with self.assertRaises(TypeError):
self.store.add(self.ctx, "衣服", owner=2)
def test_arbitrary_categories_and_long_descriptions(self):
for category in ("内衣", "内裤", "袜子", "首饰", "自定义分类"):
record = self.store.add(self.ctx, category, "细" * 2000, category)
self.assertEqual(self.store.list(self.ctx, category)[0], record)
self.assertIn("细" * 2000, format_item(record))
self.assertEqual(len(self.store.list(ctx(2))), 1) # no other defaults
def test_string_bounds_and_controls(self):
for field, minimum, maximum in (("name", 1, 80), ("description", 0, 2000), ("category", 1, 40)):
for invalid in ("x" * (maximum + 1), "x\x00", "x\n", "x\t", "x\x7f", "x\u202e", 123):
args = {"name": "衣服", field: invalid}
with self.subTest(field=field, invalid=repr(invalid)[:40]):
with self.assertRaises(ValueError):
self.store.add(self.ctx, **args)
with self.assertRaises(ValueError):
self.store.update(self.ctx, DEFAULT_ID, **{field: invalid})
if minimum:
with self.assertRaises(ValueError):
self.store.add(self.ctx, **{"name": "衣服", field: " "})
valid = self.store.add(self.ctx, **{"name": "衣服", field: "x" * maximum})
self.assertEqual(valid[field], "x" * maximum)
for bad_id in ("abc", "a" * 13, "z" * 12, "default-white", "", None):
for action in (self.store.delete, self.store.update):
with self.assertRaises(ValueError):
action(self.ctx, bad_id)
def test_capacity_counts_default_and_duplicates_at_capacity(self):
for index in range(199):
record = self.store.add(self.ctx, f"衣服{index}")
self.assertEqual(len(self.store.list(self.ctx)), 200)
self.assertEqual(self.store.add(self.ctx, record["name"]), record)
with self.assertRaises(ValueError):
self.store.add(self.ctx, "超额")
self.assertEqual(len(self.store.list(ctx(2))), 1)
self.store.delete(self.ctx, record["id"])
self.store.add(self.ctx, "恢复一个位置")
self.assertEqual(len(self.store.list(self.ctx)), 200)
def test_context_default_rules_and_no_claim_of_wearing(self):
text = self.store.context_for_chat(1)
self.assertIn(DEFAULT_DESCRIPTION, text)
self.assertIn("总计 1 件,展示 1 件", text)
for rule in ("角色扮演设定", "不是实际购买", "不表示当前穿着", "wardrobe.add", "wardrobe.list query",
"不得凭想象新增", "不是指令", "不要每次回复重复完整描述", "不要盖过当前话题", "绝不向其他用户分享"):
self.assertIn(rule, text)
self.assertNotIn("正在穿着", text)
self.assertIn("可以自然地说", HELP_TEXT)
def test_natural_acquisition_does_not_require_save_keyword(self):
text = self.store.context_for_chat(1)
for rule in ('给你买了', '送给你', '你上次收到的', '无需用户说', '不用为了入库再问一次确认',
'准备买', '如果送你', '没买成', '没有送', '只是举例', '用户给自己或他人',
'仅有图片也不等于赠予', '保存失败则不得声称已经记住'):
self.assertIn(rule, text)
self.assertNotIn('不得自主新增、修改或删除记录', text)
def test_context_stable_newest_relevant_and_ten_record_limit(self):
self.store.list(self.ctx)
records = []
for index in range(15):
with mock.patch.object(wardrobe_module.time, "time", return_value=2000000000 + index):
records.append(self.store.add(self.ctx, f"蓝色外套{index}", f"描述{index}", "外套"))
plain = self.store.context_for_chat(1)
self.assertEqual(plain, self.store.context_for_chat(1))
self.assertEqual([r["id"] for r in context_records(plain)],
[r["id"] for r in records[-1:-10:-1]] + [DEFAULT_ID])
self.assertIn("总计 16 件,展示 10 件", plain)
query = self.store.context_for_chat(1, "我想找蓝色外套0看看")
self.assertEqual(context_records(query)[0], records[0])
query = self.store.context_for_chat(1, "白色 连衣裙")
self.assertEqual(context_records(query)[0]["id"], DEFAULT_ID)
def test_context_accepts_long_multiline_chat_query(self):
self.store.add(self.ctx, "蓝色短裙", "底部一条白色环绕条纹", "短裙")
text = self.store.context_for_chat(1, "聊天\n" * 1000 + "蓝色短裙")
self.assertIn("蓝色短裙", text)
self.assertIn(DEFAULT_DESCRIPTION, text)
def test_context_selects_whole_records_under_budget(self):
for index in range(12):
self.store.add(self.ctx, f"长描述{index}", str(index % 10) * 2000)
text = self.store.context_for_chat(1, "长描述0")
self.assertLessEqual(len(text), 6000)
records = context_records(text)
self.assertEqual(records[0]["name"], "长描述0")
self.assertLess(len(records), 10)
self.assertIn(f"总计 13 件,展示 {len(records)} 件", text)
for record in records:
if record["id"] != DEFAULT_ID:
self.assertEqual(len(record["description"]), 2000)
self.assertIn(record, self.store.list(self.ctx))
# JSON escaping can make a valid record too large; omit, never truncate it.
self.store.add(self.ctx, "引号", '"' * 2000)
self.assertLessEqual(len(self.store.context_for_chat(1)), 6000)
self.assertEqual(self.store.list(self.ctx, "引号")[0]["description"], '"' * 2000)
if __name__ == "__main__":
unittest.main()
+123
View File
@@ -0,0 +1,123 @@
"""Private wardrobe tool, scope and prompt integration; no network or real state."""
import json
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / 'bot-utility'), str(BASE)]
import wardrobe as module
from agent import ToolContext, ToolExecutor
from chat import engine
class WardrobeIntegrationTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.store = module.WardrobeStore(Path(self.tmp.name) / 'wardrobe.db')
self.channel = SimpleNamespace(name='tg', is_admin=lambda uid: False)
self.ctx = ToolContext(self.channel, 'private', 1001, 123)
self.other = ToolContext(self.channel, 'private', 1002, 456)
wrapped = SimpleNamespace(wardrobe=self.store, format_item=module.format_item)
self.executor = ToolExecutor(artifacts=MagicMock(), modules={'wardrobe': wrapped})
def tearDown(self):
if hasattr(self.store, 'close'):
self.store.close()
self.tmp.cleanup()
async def test_add_and_read_full_description_and_other_owner_denied(self):
description = '一条蓝色的、底部带一条白色环绕条纹的短裙。'
result = await self.executor.execute('wardrobe', {'action': 'add', 'params': {
'name': '蓝色短裙', 'category': '短裙', 'description': description}}, self.ctx)
self.assertTrue(result.ok, result.safe_message)
item = result.data['item']
self.assertEqual(item['description'], description)
read = await self.executor.execute('wardrobe', {'action': 'list', 'params': {'query': '蓝色'}}, self.ctx)
self.assertEqual(read.data['items'][0]['description'], description)
failed = await self.executor.execute('wardrobe', {'action': 'delete', 'params': {'id': item['id']}}, self.other)
self.assertFalse(failed.ok)
self.assertIn('蓝色短裙', self.store.context_for_chat(1001))
self.assertNotIn('蓝色短裙', self.store.context_for_chat(1002))
async def test_tool_receipts_never_expose_record_json_or_metadata(self):
added = await self.executor.execute('wardrobe', {'action': 'add', 'params': {
'name': '蓝色短裙', 'description': '白色环绕条纹'}}, self.ctx)
item_id = added.data['item']['id']
updated = await self.executor.execute('wardrobe', {'action': 'update', 'params': {
'id': item_id, 'description': '裙摆底部白色环绕条纹'}}, self.ctx)
listed = await self.executor.execute('wardrobe', {'action': 'list', 'params': {}}, self.ctx)
deleted = await self.executor.execute('wardrobe', {'action': 'delete', 'params': {'id': item_id}}, self.ctx)
for result in (added, updated, listed, deleted):
self.assertTrue(result.ok, result.safe_message)
self.assertNotIn('{', result.safe_message)
self.assertNotIn(item_id, result.safe_message)
self.assertNotIn('uid', result.safe_message)
for item in result.data.get('items', [result.data.get('item', {})]):
self.assertNotIn('uid', item)
self.assertNotIn('created_at', item)
self.assertNotIn('updated_at', item)
# Storage still retains full records; only outward receipts were changed.
persisted = self.store.list(self.ctx)[0]
self.assertIn('uid', persisted)
async def test_group_cannot_list_or_mutate_even_as_admin(self):
group = ToolContext(self.channel, 'group', 1001, 999, is_admin=True)
self.assertNotIn('wardrobe', [x['name'] for x in self.executor.prompt_schema(group)])
self.assertIn('wardrobe', [x['name'] for x in self.executor.prompt_schema(self.ctx)])
for action, params in [('list', {}), ('add', {'name': '袜子'}),
('update', {'id': 'default-white-dress', 'name': 'x'}),
('delete', {'id': 'default-white-dress'})]:
result = await self.executor.execute('wardrobe', {'action': action, 'params': params}, group)
self.assertEqual(result.code, 'permission_denied')
async def test_model_cannot_override_owner(self):
result = await self.executor.execute('wardrobe', {'action': 'add', 'params': {
'name': '袜子', 'uid': 1002}}, self.ctx)
self.assertEqual(result.code, 'invalid_args')
async def test_list_pages_preserve_whole_records(self):
for n in range(12):
self.store.add(self.ctx, f'衣物{n}', '细节' * 900, '其他')
first = await self.executor.execute('wardrobe', {'action': 'list', 'params': {'limit': 20}}, self.ctx)
self.assertTrue(first.ok)
self.assertGreater(first.data['next_offset'], 0)
self.assertLess(first.data['next_offset'], first.data['total'])
for item in first.data['items']:
if item['id'] != 'default-white-dress': self.assertEqual(item['description'], '细节' * 900)
second = await self.executor.execute('wardrobe', {'action': 'list', 'params': {
'offset': first.data['next_offset']}}, self.ctx)
ids = {x['id'] for x in first.data['items']}
self.assertFalse(ids.intersection(x['id'] for x in second.data['items']))
def test_private_and_proactive_prompt_include_wardrobe_after_stable_instructions(self):
with patch.object(engine, 'user_persona_prompt', return_value='PERSONA'), \
patch.object(engine, 'load_user_status', return_value={'pause': False, 'sleep': False}), \
patch.object(engine.memory_store, 'get_relation', return_value=None), \
patch.object(engine.memory_store, 'list_entities', return_value=[]), \
patch.object(engine.memory_store, 'recall_facts', return_value=[]), \
patch.object(engine.memory_store, 'migrate_legacy'), \
patch.object(engine, 'get_history_within_budget', return_value=[]), \
patch.object(engine, 'get_last_active', return_value=0), \
patch.object(engine, '_sticker_prompt_summary', return_value=''), \
patch.object(engine.notebook.notebook, 'context_for_chat', return_value=''), \
patch.object(engine.wardrobe, 'wardrobe', self.store):
for is_auto in (False, True):
prompt = engine.build_system_prompt(1001, is_auto=is_auto)
self.assertIn('白色连衣裙', prompt)
self.assertLess(prompt.index('当你回复时'), prompt.index('白色连衣裙'))
self.assertNotIn('现在是 ', prompt)
async def test_group_prompt_never_reads_private_wardrobe(self):
with patch.object(engine, 'get_recent_group_history', return_value=[]), \
patch.object(engine, 'call_deepseek', new=AsyncMock(return_value=json.dumps({'message': [], 'tools': []}))), \
patch.object(engine.wardrobe.wardrobe, 'context_for_chat') as context:
await engine._call_group_model_and_reply_impl(None, 999, '你好', 0)
context.assert_not_called()
if __name__ == '__main__':
unittest.main()
+263
View File
@@ -0,0 +1,263 @@
"""Wardrobe orchestration regressions: fake model, executor and task store only."""
import json
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
import agent_loop
from agent import ToolResult
def call(name="白衬衫", description="柔软", action="add", tool="wardrobe"):
return {"name": tool, "args": {"action": action, "params": {"name": name, "description": description}}}
def request(*calls):
return {"message": [], "tools": list(calls)}
def reply(text):
return {"message": [{"content": text}], "tools": []}
class Channel:
name = "test"
def is_admin(self, uid):
return False
class Store:
last = None
def __init__(self):
self.steps = []
self.finished = None
Store.last = self
def start(self, *args):
return "fake-task"
def record(self, *args):
self.steps.append(args)
def finish(self, task, status, error=""):
self.finished = status
class Engine:
BASE_PROMPT = "GROUP_PERSONA"
user_persona_prompt = staticmethod(lambda uid: "ORIGINAL_PERSONA")
def __init__(self, responses):
self.responses = list(responses)
self.prompts = []
async def call_deepseek(self, prompt, *args, **kwargs):
polishing = "【仅润色回复正文】" in prompt
assert kwargs["reasoning"] is (not polishing)
self.prompts.append(prompt)
response = self.responses.pop(0) if self.responses else RuntimeError("outage")
if isinstance(response, Exception):
raise response
return json.dumps(response, ensure_ascii=False) if polishing and isinstance(response, dict) else response
def parse_json_response(self, raw):
return raw
class WardrobeReplyTest(unittest.IsolatedAsyncioTestCase):
async def run_loop(self, initial, responses, outcomes=None, **limits):
calls = []
outcomes = list(outcomes or [])
class Executor:
async def execute(self, name, args, ctx):
calls.append((name, args))
if outcomes:
return outcomes.pop(0)
item = {"id": len(calls), "uid": 999, "name": args["params"]["name"],
"description": "RAW_PRIVATE_DESCRIPTION", "created_at": "SECRET_TIME"}
return ToolResult(True, "ok", json.dumps(item), data={"item": item})
engine = Engine(responses)
with patch.object(agent_loop, "AgentTaskStore", Store), patch.object(agent_loop, "ToolExecutor", Executor):
with patch.multiple(agent_loop, **({"MAX_CALLS": 12, "MAX_ROUNDS": 8} | limits)):
result = await agent_loop.run_agent_loop(engine, initial, "ORIGINAL_PERSONA", "user", Channel(), "private", 1, 2)
return result, calls, engine
async def test_plain_prose_finalization_does_not_need_json_fields(self):
result, calls, model = await self.run_loop(request(call()), [None, "谢谢主人,这件我记住啦。"])
self.assertEqual(agent_loop._message_text(result), "谢谢主人,这件我记住啦。")
self.assertEqual(len(calls), 1)
self.assertEqual(result['tools'], [])
self.assertNotIn('请仅生成有效JSON', model.prompts[-1])
self.assertNotIn('RAW_PRIVATE_DESCRIPTION', model.prompts[-1])
self.assertNotIn('SECRET_TIME', model.prompts[-1])
self.assertIn('ORIGINAL_PERSONA', model.prompts[-1])
self.assertEqual(Store.last.finished, 'done')
async def test_legacy_text_envelope_can_omit_tools(self):
result, calls, _ = await self.run_loop(request(call()), [None, '{"text":"谢谢主人。"}'])
self.assertEqual(agent_loop._message_text(result), '谢谢主人。')
self.assertEqual(len(calls), 1)
def test_plain_reply_rejects_tools_and_db_dump_without_execution(self):
for raw in ('{"tools":[{"name":"wardrobe"}],"text":"x"}',
'{"id":"abc","uid":42,"name":"dress"}', '', None):
self.assertIsNone(agent_loop._plain_reply(raw))
self.assertEqual(agent_loop._plain_reply('好呀,谢谢你。'), '好呀,谢谢你。')
async def test_outage_never_exposes_raw_records(self):
result, calls, engine = await self.run_loop(request(call()), [RuntimeError("offline")])
self.assertEqual(agent_loop._message_text(result), "好呀,白衬衫已经在衣柜里啦。")
self.assertEqual(result["tools"], [])
self.assertEqual(len(engine.prompts), 2)
self.assertIn("ORIGINAL_PERSONA", engine.prompts[-1])
self.assertIn("实际工具结果", engine.prompts[-1])
async def test_same_normalized_name_paraphrase_replays(self):
result, calls, engine = await self.run_loop(
request(call("A Shirt", "丝绸")),
[request(call("a\tshirt", "轻柔丝质")), reply("记住啦")])
self.assertEqual(len(calls), 1)
self.assertIn('"code": "already_saved"', engine.prompts[-1])
self.assertIn("update", engine.prompts[-1])
self.assertIn('"id": 1', engine.prompts[-1])
async def test_outage_receipt_names_only_completed_adds(self):
result, calls, _ = await self.run_loop(request(call("红裙"), call("蓝裙"), call("未执行绿裙")), [None, None])
text = agent_loop._message_text(result)
self.assertEqual(len(calls), 2)
self.assertEqual("好呀,红裙、蓝裙已经在衣柜里啦。", text)
for forbidden in ("未执行绿裙", "RAW_PRIVATE_DESCRIPTION", "uid", "SECRET_TIME", "{"):
self.assertNotIn(forbidden, text)
async def test_different_names_both_saved(self):
_, calls, _ = await self.run_loop(request(call("红裙"), call("蓝裙")), [reply("好呀")])
self.assertEqual(len(calls), 2)
async def test_update_reversion_is_not_replayed_from_stale_cache(self):
first = call('外套', '蓝色', action='update')
second = call('外套', '红色', action='update')
_, calls, _ = await self.run_loop(request(first), [request(second), request(first), reply('改好了')])
self.assertEqual(len(calls), 3)
async def test_delete_invalidates_same_turn_add_guard(self):
added = ToolResult(True, 'ok', '已保存', data={'item': {'id': 'first', 'name': '外套'}})
removed = ToolResult(True, 'ok', '已删除', data={'item': {'id': 'first', 'name': '外套'}})
readded = ToolResult(True, 'ok', '已保存', data={'item': {'id': 'second', 'name': '外套'}})
_, calls, _ = await self.run_loop(request(call('外套')),
[request(call('外套', action='delete')), request(call('外套')), reply('收好了')],
outcomes=[added, removed, readded])
self.assertEqual(len(calls), 3)
async def test_exact_duplicates_do_not_execute(self):
_, calls, _ = await self.run_loop(request(call(), call()), [reply("好呀")])
self.assertEqual(len(calls), 1)
async def test_failed_add_can_retry(self):
failed = ToolResult(False, "busy", "暂时忙,请重试")
_, calls, _ = await self.run_loop(request(call()), [request(call()), reply("好了")], [failed])
self.assertEqual(len(calls), 2)
async def test_all_prior_successes_survive_in_bounded_ledger(self):
rounds = [request(call(f"衣服{i}")) for i in range(1, 5)]
_, _, engine = await self.run_loop(request(call("衣服0")), rounds + [reply("好呀")])
last = engine.prompts[-1]
ledger = json.loads(last.split("【累计成功操作摘要(内部)】\n")[1].split("\n【你刚才")[0])
self.assertEqual(len(ledger), 5)
for i in range(5):
self.assertIn(f"衣服{i}", " ".join(ledger))
self.assertTrue(all(len(entry) <= 400 for entry in ledger))
self.assertNotIn("RAW_PRIVATE_DESCRIPTION", " ".join(ledger))
self.assertNotIn("SECRET_TIME", " ".join(ledger))
self.assertIn("tool=wardrobe; action=add; code=ok", ledger[0])
self.assertIn('"action": "add"', last)
async def test_ledger_bound_and_no_raw_arguments(self):
rounds = [request(call(f"衣服{i}")) for i in range(1, 14)]
_, _, engine = await self.run_loop(request(call("衣服0", "password=secret")), rounds + [reply("好了")], MAX_ROUNDS=20, MAX_CALLS=20)
ledger = json.loads(engine.prompts[-1].split("【累计成功操作摘要(内部)】\n")[1].split("\n【你刚才")[0])
self.assertEqual(len(ledger), 12)
self.assertNotIn("secret", " ".join(ledger))
self.assertTrue(all(len(entry) <= 400 for entry in ledger))
async def test_read_only_queries_are_not_deduplicated(self):
for tool in ("wardrobe", "mail"):
_, calls, _ = await self.run_loop(request(call(action="list", tool=tool)),
[request(call(action="list", tool=tool)), reply("好了")])
self.assertEqual(len(calls), 2)
async def test_invalid_followup_and_invalid_final_content(self):
result, calls, engine = await self.run_loop(request(call()),
[{"tools": "invalid", "message": []}, {"tools": [], "message": [{"content": {"text": "wrong type"}}]}])
self.assertEqual(agent_loop._message_text(result), "好呀,白衬衫已经在衣柜里啦。")
self.assertEqual(len(engine.prompts), 2)
self.assertEqual(len(calls), 1)
async def test_latest_observation_keeps_all_results(self):
_, _, engine = await self.run_loop(request(call("一"), call("二")), [reply("好了")])
observation = engine.prompts[0].split("【你刚才执行了工具,以下是结构化结果】\n")[1].split("\n请根据")[0]
self.assertEqual(len(json.loads(observation)), 2)
async def test_invalid_model_fallback_status_is_not_done(self):
await self.run_loop(request(call()), [None, None])
self.assertEqual(Store.last.finished, "response_fallback")
async def test_finalization_cannot_execute_proposed_tool(self):
result, calls, engine = await self.run_loop(request(call()), [None, request(call("不该保存"))])
self.assertEqual(len(calls), 1)
self.assertEqual(result["tools"], [])
self.assertEqual(len(engine.prompts), 2)
async def test_leaked_final_response_gets_one_repair(self):
result, calls, engine = await self.run_loop(request(call()), [reply('{"id":1,"description":"private"}'), reply("谢谢你,我记住啦。")])
self.assertEqual(agent_loop._message_text(result), "谢谢你,我记住啦。")
self.assertEqual(len(engine.prompts), 2)
async def test_leaked_repair_rejected(self):
result, _, _ = await self.run_loop(request(call()), [None, reply('{"uid":1,"created_at":"today"}')])
self.assertNotIn("uid", str(result))
self.assertIn("已经在衣柜里", str(result))
async def test_empty_message_and_limits_finalize(self):
for limits, responses in [({}, [reply(""), reply("记住了")]),
({"MAX_ROUNDS": 1}, [request(call("后续")), reply("记住了")]),
({"MAX_CALLS": 1}, [request(call("后续")), reply("记住了")])]:
result, calls, engine = await self.run_loop(request(call()), responses, **limits)
self.assertEqual(agent_loop._message_text(result), "记住了")
self.assertEqual(len(calls), 1)
self.assertEqual(len(engine.prompts), 2)
async def test_mixed_failure_not_all_success(self):
good = ToolResult(True, "ok", '{"id":1,"uid":999}', data={"item": {"name": "裙子"}})
bad = ToolResult(False, "invalid_args", "衣物名称不能为空")
result, _, _ = await self.run_loop(request(call("一"), call("二")), [None], [good, bad])
text = agent_loop._message_text(result)
self.assertIn("还有操作没完成", text)
self.assertIn("衣物名称不能为空", text)
self.assertNotIn("这些衣服我记住啦", text)
self.assertNotIn("uid", text)
def test_list_names_only_and_write_fallbacks(self):
record = {"id": 4, "uid": 42, "name": "绿裙", "description": "SECRET", "created_at": "DATE"}
result = agent_loop._fallback_parsed([{"tool": "wardrobe", "action": "list", "ok": True, "safe_message": json.dumps(record), "data": {"items": [record]}}])
self.assertEqual(agent_loop._message_text(result), "衣柜里有:绿裙。")
for action, word in [("update", "改好"), ("delete", "移除")]:
text = agent_loop._message_text(agent_loop._fallback_parsed([{"tool": "wardrobe", "action": action, "ok": True, "safe_message": json.dumps(record)}]))
self.assertIn(word, text)
self.assertNotIn("SECRET", text)
def test_nonwardrobe_fallback_unchanged(self):
result = agent_loop._fallback_parsed([{"tool": "mail", "ok": True, "safe_message": "邮件已发送"}])
self.assertEqual(agent_loop._message_text(result), "已经处理好了呢。\n邮件已发送")
result = agent_loop._fallback_parsed([{"tool": "pixiv", "ok": False, "code": "send_failed", "safe_message": "发送失败 https://example.test"}])
self.assertEqual(agent_loop._message_text(result), "处理时遇到了具体问题:\npixiv(send_failed):发送失败 https://example.test")
if __name__ == "__main__":
unittest.main()