ForcePilot/backend/package/yuxi/channel/extensions/qqbot/tools/remind.py
Kris 2ab65f153f feat(channel): 添加 QQ Bot 渠道扩展
新增 QQ Bot 渠道扩展,支持在 Yuxi 平台中集成 QQ 机器人渠道。

包含以下功能模块:
- api_client: QQ API 客户端封装
- api_routes: API 路由管理
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- websocket: WebSocket 实时连接
- credentials: 凭证管理
- token: Token 管理
- outbound: 外发消息管理
- outbound_media: 媒体外发
- streaming: 流式消息处理
- streaming_media: 媒体流处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- pipeline: 消息管道
- pipeline_stages: 管道阶段
- commands: 指令处理
- commands_builtin: 内置指令
- interaction: 交互处理
- approval: 审批流程
- ark: ARK 消息
- audio: 音频处理
- media: 媒体资源
- media_chunked: 分块媒体
- media_tags: 媒体标签
- message_queue: 消息队列
- delivery: 消息送达确认
- reconnect: 重连机制
- typing_keepalive: 输入状态保活
- group_activation: 群激活
- group_gating: 群门控
- group_history: 群历史
- known_users: 已知用户
- ref_index: 引用索引
- tools: Agent 工具集成
- types: 类型定义
2026-05-21 11:35:12 +08:00

223 lines
7.7 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.

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)