ForcePilot/backend/package/yuxi/channel/extensions/qqbot/tools/remind.py

223 lines
7.7 KiB
Python
Raw Normal View History

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": "要删除的提醒 IDremove 操作时使用)",
},
},
"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)