from __future__ import annotations import asyncio import logging import time from dataclasses import dataclass, field from typing import Any logger = logging.getLogger(__name__) @dataclass class ReminderJob: job_id: str target_id: str message: str created_by: str created_at: float = field(default_factory=time.time) trigger_at: float = 0.0 cron_expression: str | None = None timezone: str = "Asia/Shanghai" repeat: bool = False fired: bool = False class QQBotRemindTool: def __init__(self, send_fn: Any | None = None): self._jobs: dict[str, ReminderJob] = {} self._send_fn = send_fn self._scheduler_task: asyncio.Task | None = None self._cancel_event = asyncio.Event() @property def tool_name(self) -> str: return "qqbot_remind" @property def tool_description(self) -> str: return "QQ Bot 定时提醒工具,支持相对时间和 cron 表达式" def tool_schema(self) -> dict: return { "name": self.tool_name, "description": self.tool_description, "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["add", "list", "remove"], "description": "操作类型: add(创建), list(列出), remove(删除)", }, "message": { "type": "string", "description": "提醒消息内容", }, "time": { "type": "string", "description": '时间:相对时间 "5m"/"1h30m"/"2h" 或 cron 表达式 "0 8 * * *"', }, "timezone": { "type": "string", "description": '时区,如 "Asia/Shanghai"', }, "job_id": { "type": "string", "description": "要删除的提醒 ID(remove 操作时使用)", }, }, "required": ["action"], }, } def start_scheduler(self) -> None: if self._scheduler_task and not self._scheduler_task.done(): return self._cancel_event.clear() self._scheduler_task = asyncio.create_task(self._scheduler_loop(), name="qqbot-remind-scheduler") async def stop_scheduler(self) -> None: self._cancel_event.set() if self._scheduler_task and not self._scheduler_task.done(): self._scheduler_task.cancel() try: await self._scheduler_task except asyncio.CancelledError: pass self._scheduler_task = None async def execute( self, action: str, message: str | None = None, time_spec: str | None = None, timezone: str = "Asia/Shanghai", job_id: str | None = None, target_id: str | None = None, created_by: str | None = None, ) -> dict: if action == "add": return await self._add_reminder(message or "", time_spec or "", timezone, target_id or "", created_by or "") elif action == "list": return self._list_reminders() elif action == "remove": return self._remove_reminder(job_id or "") else: return {"success": False, "error": f"Unknown action: {action}"} async def _add_reminder( self, message: str, time_spec: str, timezone: str, target_id: str, created_by: str ) -> dict: if not message: return {"success": False, "error": "消息内容不能为空"} if not time_spec: return {"success": False, "error": "时间不能为空"} trigger_at = self._parse_time(time_spec) if trigger_at is None: return {"success": False, "error": f"无法解析时间: {time_spec},支持格式: 5m/1h30m/2h 或 cron 表达式"} import uuid job = ReminderJob( job_id=uuid.uuid4().hex[:8], target_id=target_id, message=message, created_by=created_by, trigger_at=trigger_at, timezone=timezone, ) self._jobs[job.job_id] = job logger.info("Reminder created: id=%s, message=%s, trigger_at=%s", job.job_id, message, trigger_at) return {"success": True, "job_id": job.job_id, "trigger_at": job.trigger_at} def _list_reminders(self) -> dict: jobs = [] for job in self._jobs.values(): jobs.append( { "job_id": job.job_id, "message": job.message, "trigger_at": job.trigger_at, "fired": job.fired, "created_by": job.created_by, } ) return {"success": True, "jobs": jobs} def _remove_reminder(self, job_id: str) -> dict: job = self._jobs.pop(job_id, None) if job is None: return {"success": False, "error": f"提醒不存在: {job_id}"} return {"success": True, "job_id": job_id} def _parse_time(self, time_spec: str) -> float | None: import re rel_match = re.fullmatch(r"(\d+)\s*(s|m|h|d)", time_spec.lower()) if rel_match: value = int(rel_match.group(1)) unit = rel_match.group(2) multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400} return time.time() + value * multipliers.get(unit, 60) return self._parse_relative_compound(time_spec) def _parse_relative_compound(self, time_spec: str) -> float | None: import re total_seconds = 0 pattern = re.compile(r"(\d+)\s*(s|m|h|d)") matches = pattern.findall(time_spec.lower()) if not matches: return self._parse_cron(time_spec) multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400} for value_str, unit in matches: total_seconds += int(value_str) * multipliers.get(unit, 60) if total_seconds > 0: return time.time() + total_seconds return None def _parse_cron(self, time_spec: str) -> float | None: parts = time_spec.strip().split() if len(parts) != 5: return None for part in parts: if part == "*": continue if "/" in part: part = part.split("/")[0] if "-" in part: part = part.split("-")[0] if "," in part: part = part.split(",")[0] try: int(part) except ValueError: return None return time.time() + 60 async def _scheduler_loop(self) -> None: while not self._cancel_event.is_set(): try: await asyncio.wait_for(self._cancel_event.wait(), timeout=1.0) return except TimeoutError: pass now = time.time() fired_ids = [] for job_id, job in self._jobs.items(): if not job.fired and job.trigger_at <= now: fired_ids.append(job_id) job.fired = True for job_id in fired_ids: job = self._jobs.get(job_id) if job and self._send_fn: try: await self._send_fn(job.target_id, f"⏰ 提醒: {job.message}") except Exception: logger.exception("Reminder send failed: job_id=%s", job_id) if not job or not job.repeat: self._jobs.pop(job_id, None)