mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-02-18 16:17:05 +08:00
444 lines
17 KiB
Python
444 lines
17 KiB
Python
"""
|
||
Scheduler tool for creating and managing scheduled tasks
|
||
"""
|
||
|
||
import uuid
|
||
from datetime import datetime
|
||
from typing import Any, Dict, Optional
|
||
from croniter import croniter
|
||
|
||
from agent.tools.base_tool import BaseTool, ToolResult
|
||
from bridge.context import Context, ContextType
|
||
from bridge.reply import Reply, ReplyType
|
||
from common.log import logger
|
||
|
||
|
||
class SchedulerTool(BaseTool):
|
||
"""
|
||
Tool for managing scheduled tasks (reminders, notifications, etc.)
|
||
"""
|
||
|
||
name: str = "scheduler"
|
||
description: str = (
|
||
"创建、查询和管理定时任务(提醒、周期性任务等)。\n\n"
|
||
"⚠️ 重要:仅当需要「定时/提醒/每天/每周/X分钟后/X点」等延迟或周期执行时才使用此工具。"
|
||
"使用方法:\n"
|
||
"- 创建:action='create', name='任务名', message/ai_task='内容', schedule_type='once/interval/cron', schedule_value='...'\n"
|
||
"- 查询:action='list' / action='get', task_id='任务ID'\n"
|
||
"- 管理:action='delete/enable/disable', task_id='任务ID'\n\n"
|
||
"调度类型:\n"
|
||
"- once: 一次性任务,支持相对时间(+5s,+10m,+1h,+1d)或ISO时间\n"
|
||
"- interval: 固定间隔(秒),如3600表示每小时\n"
|
||
"- cron: cron表达式,如'0 8 * * *'表示每天8点\n\n"
|
||
"注意:'X秒后'用once+相对时间,'每X秒'用interval"
|
||
)
|
||
params: dict = {
|
||
"type": "object",
|
||
"properties": {
|
||
"action": {
|
||
"type": "string",
|
||
"enum": ["create", "list", "get", "delete", "enable", "disable"],
|
||
"description": "操作类型: create(创建), list(列表), get(查询), delete(删除), enable(启用), disable(禁用)"
|
||
},
|
||
"task_id": {
|
||
"type": "string",
|
||
"description": "任务ID (用于 get/delete/enable/disable 操作)"
|
||
},
|
||
"name": {
|
||
"type": "string",
|
||
"description": "任务名称 (用于 create 操作)"
|
||
},
|
||
"message": {
|
||
"type": "string",
|
||
"description": "固定消息内容 (与ai_task二选一)"
|
||
},
|
||
"ai_task": {
|
||
"type": "string",
|
||
"description": "AI任务描述 (与message二选一),用于定时让AI执行的任务"
|
||
},
|
||
"schedule_type": {
|
||
"type": "string",
|
||
"enum": ["cron", "interval", "once"],
|
||
"description": "调度类型 (用于 create 操作): cron(cron表达式), interval(固定间隔秒数), once(一次性)"
|
||
},
|
||
"schedule_value": {
|
||
"type": "string",
|
||
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
|
||
}
|
||
},
|
||
"required": ["action"]
|
||
}
|
||
|
||
def __init__(self, config: dict = None):
|
||
super().__init__()
|
||
self.config = config or {}
|
||
|
||
# Will be set by agent bridge
|
||
self.task_store = None
|
||
self.current_context = None
|
||
|
||
def execute(self, params: dict) -> ToolResult:
|
||
"""
|
||
Execute scheduler operations
|
||
|
||
Args:
|
||
params: Dictionary containing:
|
||
- action: Operation type (create/list/get/delete/enable/disable)
|
||
- Other parameters depending on action
|
||
|
||
Returns:
|
||
ToolResult object
|
||
"""
|
||
# Extract parameters
|
||
action = params.get("action")
|
||
kwargs = params
|
||
|
||
if not self.task_store:
|
||
return ToolResult.fail("错误: 定时任务系统未初始化")
|
||
|
||
try:
|
||
if action == "create":
|
||
result = self._create_task(**kwargs)
|
||
return ToolResult.success(result)
|
||
elif action == "list":
|
||
result = self._list_tasks(**kwargs)
|
||
return ToolResult.success(result)
|
||
elif action == "get":
|
||
result = self._get_task(**kwargs)
|
||
return ToolResult.success(result)
|
||
elif action == "delete":
|
||
result = self._delete_task(**kwargs)
|
||
return ToolResult.success(result)
|
||
elif action == "enable":
|
||
result = self._enable_task(**kwargs)
|
||
return ToolResult.success(result)
|
||
elif action == "disable":
|
||
result = self._disable_task(**kwargs)
|
||
return ToolResult.success(result)
|
||
else:
|
||
return ToolResult.fail(f"未知操作: {action}")
|
||
except Exception as e:
|
||
logger.error(f"[SchedulerTool] Error: {e}")
|
||
return ToolResult.fail(f"操作失败: {str(e)}")
|
||
|
||
def _create_task(self, **kwargs) -> str:
|
||
"""Create a new scheduled task"""
|
||
name = kwargs.get("name")
|
||
message = kwargs.get("message")
|
||
ai_task = kwargs.get("ai_task")
|
||
schedule_type = kwargs.get("schedule_type")
|
||
schedule_value = kwargs.get("schedule_value")
|
||
|
||
# Validate required fields
|
||
if not name:
|
||
return "错误: 缺少任务名称 (name)"
|
||
|
||
# Check that exactly one of message/ai_task is provided
|
||
if not message and not ai_task:
|
||
return "错误: 必须提供 message(固定消息)或 ai_task(AI任务)之一"
|
||
if message and ai_task:
|
||
return "错误: message 和 ai_task 只能提供其中一个"
|
||
|
||
if not schedule_type:
|
||
return "错误: 缺少调度类型 (schedule_type)"
|
||
if not schedule_value:
|
||
return "错误: 缺少调度值 (schedule_value)"
|
||
|
||
# Validate schedule
|
||
schedule = self._parse_schedule(schedule_type, schedule_value)
|
||
if not schedule:
|
||
return f"错误: 无效的调度配置 - type: {schedule_type}, value: {schedule_value}"
|
||
|
||
# Get context info for receiver
|
||
if not self.current_context:
|
||
return "错误: 无法获取当前对话上下文"
|
||
|
||
context = self.current_context
|
||
|
||
# Create task
|
||
task_id = str(uuid.uuid4())[:8]
|
||
|
||
# Build action based on message or ai_task
|
||
if message:
|
||
action = {
|
||
"type": "send_message",
|
||
"content": message,
|
||
"receiver": context.get("receiver"),
|
||
"receiver_name": self._get_receiver_name(context),
|
||
"is_group": context.get("isgroup", False),
|
||
"channel_type": self.config.get("channel_type", "unknown")
|
||
}
|
||
else: # ai_task
|
||
action = {
|
||
"type": "agent_task",
|
||
"task_description": ai_task,
|
||
"receiver": context.get("receiver"),
|
||
"receiver_name": self._get_receiver_name(context),
|
||
"is_group": context.get("isgroup", False),
|
||
"channel_type": self.config.get("channel_type", "unknown")
|
||
}
|
||
|
||
# 针对钉钉单聊,额外存储 sender_staff_id
|
||
msg = context.kwargs.get("msg")
|
||
if msg and hasattr(msg, 'sender_staff_id') and not context.get("isgroup", False):
|
||
action["dingtalk_sender_staff_id"] = msg.sender_staff_id
|
||
|
||
task_data = {
|
||
"id": task_id,
|
||
"name": name,
|
||
"enabled": True,
|
||
"created_at": datetime.now().isoformat(),
|
||
"updated_at": datetime.now().isoformat(),
|
||
"schedule": schedule,
|
||
"action": action
|
||
}
|
||
|
||
# Calculate initial next_run_at
|
||
next_run = self._calculate_next_run(task_data)
|
||
if next_run:
|
||
task_data["next_run_at"] = next_run.isoformat()
|
||
|
||
# Save task
|
||
self.task_store.add_task(task_data)
|
||
|
||
# Format response
|
||
schedule_desc = self._format_schedule_description(schedule)
|
||
receiver_desc = task_data["action"]["receiver_name"] or task_data["action"]["receiver"]
|
||
|
||
if message:
|
||
content_desc = f"💬 固定消息: {message}"
|
||
else:
|
||
content_desc = f"🤖 AI任务: {ai_task}"
|
||
|
||
return (
|
||
f"✅ 定时任务创建成功\n\n"
|
||
f"📋 任务ID: {task_id}\n"
|
||
f"📝 名称: {name}\n"
|
||
f"⏰ 调度: {schedule_desc}\n"
|
||
f"👤 接收者: {receiver_desc}\n"
|
||
f"{content_desc}\n"
|
||
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
|
||
)
|
||
|
||
def _list_tasks(self, **kwargs) -> str:
|
||
"""List all tasks"""
|
||
tasks = self.task_store.list_tasks()
|
||
|
||
if not tasks:
|
||
return "📋 暂无定时任务"
|
||
|
||
lines = [f"📋 定时任务列表 (共 {len(tasks)} 个)\n"]
|
||
|
||
for task in tasks:
|
||
status = "✅" if task.get("enabled", True) else "❌"
|
||
schedule_desc = self._format_schedule_description(task.get("schedule", {}))
|
||
next_run = task.get("next_run_at")
|
||
next_run_str = datetime.fromisoformat(next_run).strftime('%m-%d %H:%M') if next_run else "未知"
|
||
|
||
lines.append(
|
||
f"{status} [{task['id']}] {task['name']}\n"
|
||
f" ⏰ {schedule_desc} | 下次: {next_run_str}"
|
||
)
|
||
|
||
return "\n".join(lines)
|
||
|
||
def _get_task(self, **kwargs) -> str:
|
||
"""Get task details"""
|
||
task_id = kwargs.get("task_id")
|
||
if not task_id:
|
||
return "错误: 缺少任务ID (task_id)"
|
||
|
||
task = self.task_store.get_task(task_id)
|
||
if not task:
|
||
return f"错误: 任务 '{task_id}' 不存在"
|
||
|
||
status = "启用" if task.get("enabled", True) else "禁用"
|
||
schedule_desc = self._format_schedule_description(task.get("schedule", {}))
|
||
action = task.get("action", {})
|
||
next_run = task.get("next_run_at")
|
||
next_run_str = datetime.fromisoformat(next_run).strftime('%Y-%m-%d %H:%M:%S') if next_run else "未知"
|
||
last_run = task.get("last_run_at")
|
||
last_run_str = datetime.fromisoformat(last_run).strftime('%Y-%m-%d %H:%M:%S') if last_run else "从未执行"
|
||
|
||
return (
|
||
f"📋 任务详情\n\n"
|
||
f"ID: {task['id']}\n"
|
||
f"名称: {task['name']}\n"
|
||
f"状态: {status}\n"
|
||
f"调度: {schedule_desc}\n"
|
||
f"接收者: {action.get('receiver_name', action.get('receiver'))}\n"
|
||
f"消息: {action.get('content')}\n"
|
||
f"下次执行: {next_run_str}\n"
|
||
f"上次执行: {last_run_str}\n"
|
||
f"创建时间: {datetime.fromisoformat(task['created_at']).strftime('%Y-%m-%d %H:%M:%S')}"
|
||
)
|
||
|
||
def _delete_task(self, **kwargs) -> str:
|
||
"""Delete a task"""
|
||
task_id = kwargs.get("task_id")
|
||
if not task_id:
|
||
return "错误: 缺少任务ID (task_id)"
|
||
|
||
task = self.task_store.get_task(task_id)
|
||
if not task:
|
||
return f"错误: 任务 '{task_id}' 不存在"
|
||
|
||
self.task_store.delete_task(task_id)
|
||
return f"✅ 任务 '{task['name']}' ({task_id}) 已删除"
|
||
|
||
def _enable_task(self, **kwargs) -> str:
|
||
"""Enable a task"""
|
||
task_id = kwargs.get("task_id")
|
||
if not task_id:
|
||
return "错误: 缺少任务ID (task_id)"
|
||
|
||
task = self.task_store.get_task(task_id)
|
||
if not task:
|
||
return f"错误: 任务 '{task_id}' 不存在"
|
||
|
||
self.task_store.enable_task(task_id, True)
|
||
return f"✅ 任务 '{task['name']}' ({task_id}) 已启用"
|
||
|
||
def _disable_task(self, **kwargs) -> str:
|
||
"""Disable a task"""
|
||
task_id = kwargs.get("task_id")
|
||
if not task_id:
|
||
return "错误: 缺少任务ID (task_id)"
|
||
|
||
task = self.task_store.get_task(task_id)
|
||
if not task:
|
||
return f"错误: 任务 '{task_id}' 不存在"
|
||
|
||
self.task_store.enable_task(task_id, False)
|
||
return f"✅ 任务 '{task['name']}' ({task_id}) 已禁用"
|
||
|
||
def _parse_schedule(self, schedule_type: str, schedule_value: str) -> Optional[dict]:
|
||
"""Parse and validate schedule configuration"""
|
||
try:
|
||
if schedule_type == "cron":
|
||
# Validate cron expression
|
||
croniter(schedule_value)
|
||
return {"type": "cron", "expression": schedule_value}
|
||
|
||
elif schedule_type == "interval":
|
||
# Parse interval in seconds
|
||
seconds = int(schedule_value)
|
||
if seconds <= 0:
|
||
return None
|
||
return {"type": "interval", "seconds": seconds}
|
||
|
||
elif schedule_type == "once":
|
||
# Parse datetime - support both relative and absolute time
|
||
|
||
# Check if it's relative time (e.g., "+5s", "+10m", "+1h", "+1d")
|
||
if schedule_value.startswith("+"):
|
||
import re
|
||
match = re.match(r'\+(\d+)([smhd])', schedule_value)
|
||
if match:
|
||
amount = int(match.group(1))
|
||
unit = match.group(2)
|
||
|
||
from datetime import timedelta
|
||
now = datetime.now()
|
||
|
||
if unit == 's': # seconds
|
||
target_time = now + timedelta(seconds=amount)
|
||
elif unit == 'm': # minutes
|
||
target_time = now + timedelta(minutes=amount)
|
||
elif unit == 'h': # hours
|
||
target_time = now + timedelta(hours=amount)
|
||
elif unit == 'd': # days
|
||
target_time = now + timedelta(days=amount)
|
||
else:
|
||
return None
|
||
|
||
return {"type": "once", "run_at": target_time.isoformat()}
|
||
else:
|
||
logger.error(f"[SchedulerTool] Invalid relative time format: {schedule_value}")
|
||
return None
|
||
else:
|
||
# Absolute time in ISO format
|
||
datetime.fromisoformat(schedule_value)
|
||
return {"type": "once", "run_at": schedule_value}
|
||
|
||
except Exception as e:
|
||
logger.error(f"[SchedulerTool] Invalid schedule: {e}")
|
||
return None
|
||
|
||
return None
|
||
|
||
def _calculate_next_run(self, task: dict) -> Optional[datetime]:
|
||
"""Calculate next run time for a task"""
|
||
schedule = task.get("schedule", {})
|
||
schedule_type = schedule.get("type")
|
||
now = datetime.now()
|
||
|
||
if schedule_type == "cron":
|
||
expression = schedule.get("expression")
|
||
cron = croniter(expression, now)
|
||
return cron.get_next(datetime)
|
||
|
||
elif schedule_type == "interval":
|
||
seconds = schedule.get("seconds", 0)
|
||
from datetime import timedelta
|
||
return now + timedelta(seconds=seconds)
|
||
|
||
elif schedule_type == "once":
|
||
run_at_str = schedule.get("run_at")
|
||
return datetime.fromisoformat(run_at_str)
|
||
|
||
return None
|
||
|
||
def _format_schedule_description(self, schedule: dict) -> str:
|
||
"""Format schedule as human-readable description"""
|
||
schedule_type = schedule.get("type")
|
||
|
||
if schedule_type == "cron":
|
||
expr = schedule.get("expression", "")
|
||
# Try to provide friendly description
|
||
if expr == "0 9 * * *":
|
||
return "每天 9:00"
|
||
elif expr == "0 */1 * * *":
|
||
return "每小时"
|
||
elif expr == "*/30 * * * *":
|
||
return "每30分钟"
|
||
else:
|
||
return f"Cron: {expr}"
|
||
|
||
elif schedule_type == "interval":
|
||
seconds = schedule.get("seconds", 0)
|
||
if seconds >= 86400:
|
||
days = seconds // 86400
|
||
return f"每 {days} 天"
|
||
elif seconds >= 3600:
|
||
hours = seconds // 3600
|
||
return f"每 {hours} 小时"
|
||
elif seconds >= 60:
|
||
minutes = seconds // 60
|
||
return f"每 {minutes} 分钟"
|
||
else:
|
||
return f"每 {seconds} 秒"
|
||
|
||
elif schedule_type == "once":
|
||
run_at = schedule.get("run_at", "")
|
||
try:
|
||
dt = datetime.fromisoformat(run_at)
|
||
return f"一次性 ({dt.strftime('%Y-%m-%d %H:%M')})"
|
||
except:
|
||
return "一次性"
|
||
|
||
return "未知"
|
||
|
||
def _get_receiver_name(self, context: Context) -> str:
|
||
"""Get receiver name from context"""
|
||
try:
|
||
msg = context.get("msg")
|
||
if msg:
|
||
if context.get("isgroup"):
|
||
return msg.other_user_nickname or "群聊"
|
||
else:
|
||
return msg.from_user_nickname or "用户"
|
||
except:
|
||
pass
|
||
return "未知"
|