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