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