Files
shirobana-bot/bot-utility/scheduler.py
T
RRRosmontis 34089d0042 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.
2026-11-11 00:25:30 +08:00

406 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Persistent once, daily, and weekly reminders backed by SQLite."""
from __future__ import annotations
import asyncio
import json
import os
import re
import secrets
import sqlite3
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Mapping
from zoneinfo import ZoneInfo
import notifications
SHANGHAI = ZoneInfo("Asia/Shanghai")
BASE_DIR = Path(__file__).resolve().parent
DB_PATH = os.environ.get("AGENT_DB_PATH", str(BASE_DIR / "data" / "agent.db"))
_ALLOWED_PARAMS = {"message", "kind", "run_at", "time_of_day", "weekdays"}
_TIME_RE = re.compile(r"^(\d{2}):(\d{2})(?::(\d{2}))?$")
_KIND_NAMES = {"once": "单次", "daily": "每天", "weekly": "每周"}
FIRING_LEASE_SECONDS = 300.0
def _epoch(value: datetime | float | int | None) -> float:
if value is None:
return time.time()
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=SHANGHAI)
return value.timestamp()
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError("now 必须是 datetime 或 epoch 秒数")
return float(value)
def _local_datetime(value: float) -> datetime:
return datetime.fromtimestamp(value, SHANGHAI)
def _time_parts(value: Any) -> tuple[int, int, int]:
if not isinstance(value, str):
raise ValueError("time_of_day 必须是 HH:MM 或 HH:MM:SS")
match = _TIME_RE.fullmatch(value.strip())
if not match:
raise ValueError("time_of_day 必须是 HH:MM 或 HH:MM:SS")
hour, minute = int(match[1]), int(match[2])
second = int(match[3] or 0)
if hour > 23 or minute > 59 or second > 59:
raise ValueError("time_of_day 不是有效时间")
return hour, minute, second
def _next_daily(now: float, time_of_day: Any) -> float:
hour, minute, second = _time_parts(time_of_day)
local = _local_datetime(now)
candidate = local.replace(hour=hour, minute=minute, second=second, microsecond=0)
if candidate.timestamp() <= now:
candidate += timedelta(days=1)
return candidate.timestamp()
def _weekdays(value: Any) -> list[int]:
if not isinstance(value, list) or not value:
raise ValueError("weekly 必须提供非空 weekdays 列表")
if any(isinstance(day, bool) or not isinstance(day, int) or not 1 <= day <= 7 for day in value):
raise ValueError("weekdays 只能包含 1..7 的整数")
return sorted(set(value))
def _next_weekly(now: float, time_of_day: Any, weekdays: Any) -> float:
days = _weekdays(weekdays)
hour, minute, second = _time_parts(time_of_day)
local = _local_datetime(now)
midnight = local.replace(hour=0, minute=0, second=0, microsecond=0)
for offset in range(8):
day = midnight + timedelta(days=offset)
if day.isoweekday() not in days:
continue
candidate = day.replace(hour=hour, minute=minute, second=second)
if candidate.timestamp() > now:
return candidate.timestamp()
raise AssertionError("weekly next run calculation failed")
def _next_run(kind: str, now: float, params: Mapping[str, Any]) -> float:
if kind == "daily":
return _next_daily(now, params.get("time_of_day"))
if kind == "weekly":
return _next_weekly(now, params.get("time_of_day"), params.get("weekdays"))
raise ValueError("不支持的重复类型")
def _epoch_text(value: float) -> str:
if float(value).is_integer():
return str(int(value))
return f"{value:.6f}".rstrip("0").rstrip(".")
def _row_dict(row: sqlite3.Row | Mapping[str, Any]) -> dict[str, Any]:
task = dict(row)
if task.get("weekdays"):
task["weekdays"] = json.loads(task["weekdays"])
else:
task["weekdays"] = None
next_run_at = task.get("next_run_at")
task["next_run_text"] = (
_local_datetime(float(next_run_at)).strftime("%Y-%m-%d %H:%M:%S")
if next_run_at is not None else None
)
return task
def format_task(task: Mapping[str, Any]) -> str:
"""Render one task as concise Chinese text for a user."""
kind = str(task.get("kind", ""))
next_run_at = task.get("next_run_at")
next_text = task.get("next_run_text")
if next_text is None and next_run_at is not None:
next_text = _local_datetime(float(next_run_at)).strftime("%Y-%m-%d %H:%M:%S")
return (
f"ID:{task.get('id', '')}\n"
f"类型:{_KIND_NAMES.get(kind, kind)}\n"
f"消息:{task.get('message', '')}\n"
f"下次时间:{next_text or '无'}"
)
HELP_TEXT = """定时提醒用法:
- 创建:直接用自然语言说“今天下午6点提醒我开会”(单次)、“每天08:30提醒我签到”(每天)或“每周一、周五18:00提醒我写周报”(每周)。也可以提供 message、kind、run_at、time_of_day、weekdays 参数。
- 查看:说“查看我的定时提醒”或“提醒列表”,只显示当前私聊或群聊中的有效提醒。
- 取消:说“取消提醒 <ID>”,ID 可以只写能唯一匹配当前会话任务的前缀。
时间均按 Asia/Shanghai(北京时间)计算。"""
class Scheduler:
def __init__(self, db_path: str = DB_PATH):
self.db_path = str(db_path)
self._lock = threading.RLock()
parent = os.path.dirname(os.path.abspath(self.db_path))
os.makedirs(parent, exist_ok=True)
self._init_db()
def _connect(self) -> sqlite3.Connection:
con = sqlite3.connect(self.db_path, timeout=30)
con.row_factory = sqlite3.Row
con.execute("PRAGMA journal_mode=WAL")
return con
def _init_db(self) -> None:
with self._lock, self._connect() as con:
con.execute(
"""CREATE TABLE IF NOT EXISTS scheduled_tasks(
id TEXT PRIMARY KEY,
uid INTEGER,
channel TEXT,
msg_type TEXT,
target_id INTEGER,
message TEXT NOT NULL,
kind TEXT NOT NULL,
run_at REAL,
time_of_day TEXT,
weekdays TEXT,
next_run_at REAL,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
firing_owner TEXT,
firing_until REAL
)"""
)
task_columns = {
row["name"] for row in con.execute("PRAGMA table_info(scheduled_tasks)")
}
for name, declaration in (("firing_owner", "TEXT"), ("firing_until", "REAL")):
if name not in task_columns:
con.execute(f"ALTER TABLE scheduled_tasks ADD COLUMN {name} {declaration}")
con.execute(
"""CREATE INDEX IF NOT EXISTS scheduled_tasks_due
ON scheduled_tasks(status,next_run_at)"""
)
@staticmethod
def _context(ctx: Any) -> tuple[str, int | None, str, str, int]:
channel = str(ctx.channel.name)
msg_type = str(ctx.msg_type)
target_id = int(ctx.target_id)
uid = int(ctx.user_id) if msg_type == "private" else None
return ("private" if uid is not None else "group", uid, channel, msg_type, target_id)
@staticmethod
def _scope(ctx: Any) -> tuple[str, tuple[Any, ...]]:
if ctx.msg_type == "private":
return "uid=?", (int(ctx.user_id),)
return "channel=? AND msg_type=? AND target_id=?", (
str(ctx.channel.name), str(ctx.msg_type), int(ctx.target_id)
)
def create_from_params(
self, ctx: Any, params: Mapping[str, Any], now: datetime | float | int | None = None
) -> dict[str, Any]:
if not isinstance(params, Mapping):
raise ValueError("params 必须是对象")
unknown = set(params) - _ALLOWED_PARAMS
if unknown:
raise ValueError(f"不支持的参数:{', '.join(sorted(map(str, unknown)))}")
message = params.get("message")
if not isinstance(message, str) or not message.strip() or len(message) > 1000:
raise ValueError("message 必须是非空且不超过 1000 字的字符串")
message = message.strip()
kind = params.get("kind")
if kind not in ("once", "daily", "weekly"):
raise ValueError("kind 必须是 once、daily 或 weekly")
current = _epoch(now)
run_at = None
time_of_day = None
weekdays = None
if kind == "once":
raw_run_at = params.get("run_at")
if not isinstance(raw_run_at, str) or not raw_run_at.strip():
raise ValueError("once 必须提供 ISO8601 run_at")
try:
parsed = datetime.fromisoformat(raw_run_at.strip())
except ValueError as exc:
raise ValueError("run_at 必须是有效的 ISO8601 时间") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=SHANGHAI)
run_at = parsed.timestamp()
if run_at <= current:
raise ValueError("run_at 必须是未来时间")
next_run_at = run_at
else:
time_of_day = params.get("time_of_day")
# Validate and canonicalize HH:MM to HH:MM:SS for persistent storage.
hour, minute, second = _time_parts(time_of_day)
time_of_day = f"{hour:02d}:{minute:02d}:{second:02d}"
if kind == "daily":
next_run_at = _next_daily(current, time_of_day)
else:
days = _weekdays(params.get("weekdays"))
weekdays = json.dumps(days)
next_run_at = _next_weekly(current, time_of_day, days)
_, uid, channel, msg_type, target_id = self._context(ctx)
with self._lock, self._connect() as con:
while True:
task_id = secrets.token_hex(4)
if not con.execute("SELECT 1 FROM scheduled_tasks WHERE id=?", (task_id,)).fetchone():
break
con.execute(
"""INSERT INTO scheduled_tasks(
id,uid,channel,msg_type,target_id,message,kind,run_at,
time_of_day,weekdays,next_run_at,created_at,updated_at,status
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,'active')""",
(task_id, uid, channel, msg_type, target_id, message, kind, run_at,
time_of_day, weekdays, next_run_at, current, current),
)
row = con.execute("SELECT * FROM scheduled_tasks WHERE id=?", (task_id,)).fetchone()
return _row_dict(row)
def list_for_context(self, ctx: Any) -> list[dict[str, Any]]:
where, args = self._scope(ctx)
with self._lock, self._connect() as con:
rows = con.execute(
f"""SELECT * FROM scheduled_tasks
WHERE status='active' AND {where}
ORDER BY next_run_at,id""",
args,
).fetchall()
return [_row_dict(row) for row in rows]
def cancel_for_context(self, ctx: Any, id_prefix: str) -> dict[str, Any]:
if not isinstance(id_prefix, str) or not id_prefix.strip():
raise ValueError("必须提供任务 ID 前缀")
prefix = id_prefix.strip()
where, args = self._scope(ctx)
with self._lock, self._connect() as con:
rows = con.execute(
f"SELECT * FROM scheduled_tasks WHERE status='active' AND {where}", args
).fetchall()
matches = [row for row in rows if str(row["id"]).startswith(prefix)]
if len(matches) != 1:
raise ValueError("任务不存在、无权取消或 ID 前缀不唯一")
task_id = matches[0]["id"]
cursor = con.execute(
"""UPDATE scheduled_tasks SET status='cancelled',updated_at=?
WHERE id=? AND status='active'""",
(time.time(), task_id),
)
if cursor.rowcount != 1:
raise ValueError("任务已不可取消")
row = con.execute("SELECT * FROM scheduled_tasks WHERE id=?", (task_id,)).fetchone()
return _row_dict(row)
def _claim_task(self, row: sqlite3.Row, current: float, owner: str) -> bool:
"""Serialize firing with cancellation and recover leases left by crashes."""
with self._lock, self._connect() as con:
cursor = con.execute(
"""UPDATE scheduled_tasks
SET status='firing',firing_owner=?,firing_until=?,updated_at=?
WHERE id=? AND next_run_at=?
AND (status='active'
OR (status='firing' AND firing_until<=?))""",
(owner, current + FIRING_LEASE_SECONDS, current,
row["id"], row["next_run_at"], current),
)
return cursor.rowcount == 1
def _release_task(self, task_id: str, owner: str, current: float) -> None:
with self._lock, self._connect() as con:
con.execute(
"""UPDATE scheduled_tasks
SET status='active',firing_owner=NULL,firing_until=NULL,updated_at=?
WHERE id=? AND status='firing' AND firing_owner=?""",
(current, task_id, owner),
)
def fire_due(
self,
now: datetime | float | int | None = None,
limit: int = 20,
center: Any = None,
) -> int:
current = _epoch(now)
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1:
raise ValueError("limit 必须是正整数")
publisher = notifications.center if center is None else center
with self._lock, self._connect() as con:
rows = con.execute(
"""SELECT * FROM scheduled_tasks
WHERE next_run_at<=?
AND (status='active'
OR (status='firing' AND firing_until<=?))
ORDER BY next_run_at,id LIMIT ?""",
(current, current, limit),
).fetchall()
fired = 0
for row in rows:
scheduled_at = float(row["next_run_at"])
owner = secrets.token_hex(16)
# A cancellation that commits after SELECT but before this atomic
# claim wins; such a task must never reach publish().
if not self._claim_task(row, current, owner):
continue
try:
if row["kind"] == "once":
next_run_at = scheduled_at
next_status = "completed"
else:
next_run_at = _next_run(
row["kind"], current,
{"time_of_day": row["time_of_day"],
"weekdays": json.loads(row["weekdays"] or "[]")},
)
next_status = "active"
event = notifications.Event(
kind="reminder",
dedupe_key=f"reminder:{row['id']}:{_epoch_text(scheduled_at)}",
uid=row["uid"] if row["msg_type"] == "private" else None,
channel=row["channel"],
msg_type=row["msg_type"],
target_id=row["target_id"],
legacy_text="定时提醒:" + row["message"],
payload={
"task_id": row["id"],
"message": row["message"],
"kind": row["kind"],
"scheduled_at": int(scheduled_at) if scheduled_at.is_integer() else scheduled_at,
},
)
event_id = publisher.publish(event)
except Exception:
self._release_task(row["id"], owner, current)
raise
if not event_id:
# No enabled target means the producer did not accept work.
# Keep the occurrence due so a later binding can accept it.
self._release_task(row["id"], owner, current)
continue
with self._lock, self._connect() as con:
cursor = con.execute(
"""UPDATE scheduled_tasks
SET next_run_at=?,status=?,updated_at=?,
firing_owner=NULL,firing_until=NULL
WHERE id=? AND status='firing' AND firing_owner=?
AND next_run_at=?""",
(next_run_at, next_status, current, row["id"], owner, scheduled_at),
)
if cursor.rowcount == 1:
fired += 1
return fired
async def worker(self, interval: float = 2) -> None:
while True:
try:
self.fire_due()
except Exception as exc:
print(f"[scheduler] worker: {type(exc).__name__}: {exc}")
await asyncio.sleep(interval)
scheduler = Scheduler()