新增 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: 类型定义
223 lines
7.7 KiB
Python
223 lines
7.7 KiB
Python
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) |