#!/usr/bin/env python3 from __future__ import annotations import argparse import json import math import os import re import socket import sys import urllib.error import urllib.request from datetime import datetime, timedelta, timezone from typing import Any, NoReturn try: from zoneinfo import ZoneInfo except ImportError: # pragma: no cover - Python 3.8 fallback ZoneInfo = None # type: ignore[assignment,misc] sys.stderr = sys.stdout SCHEDULE_TYPES = ("delay_once", "daily", "weekly", "cn_workday") MAX_DELAY_SECONDS = 24 * 60 * 60 class AmbiguousCreateError(RuntimeError): """The POST may have reached the server, so retrying could create a duplicate.""" class SkillArgumentParser(argparse.ArgumentParser): def error(self, message: str) -> NoReturn: raise ValueError(f"参数错误:{message}") def _shanghai_timezone(): if ZoneInfo is not None: try: return ZoneInfo("Asia/Shanghai") except Exception: pass return timezone(timedelta(hours=8), name="Asia/Shanghai") SHANGHAI_TZ = _shanghai_timezone() def _parse_args(argv: list[str]) -> argparse.Namespace: parser = SkillArgumentParser(description="创建当前微信会话的定时任务") parser.add_argument("--name", required=True) parser.add_argument( "--schedule-type", "--schedule_type", dest="schedule_type", choices=SCHEDULE_TYPES, required=True, ) parser.add_argument("--content", default="") parser.add_argument("--ai-prompt", "--ai_prompt", dest="ai_prompt", default="") parser.add_argument("--time", default="") parser.add_argument("--weekday", action="append", default=[]) parser.add_argument("--weekdays", action="append", default=[]) delay = parser.add_mutually_exclusive_group() delay.add_argument("--delay-seconds", "--delay_seconds", dest="delay_seconds", type=int) delay.add_argument("--delay-minutes", "--delay_minutes", dest="delay_minutes", type=int) delay.add_argument("--delay-hours", "--delay_hours", dest="delay_hours", type=int) delay.add_argument("--run-at", "--run_at", dest="run_at") parser.add_argument("--dry-run", action="store_true") return parser.parse_args(argv) def _require_env(name: str) -> str: value = os.environ.get(name, "").strip() if not value: raise ValueError(f"环境变量 {name} 未配置") return value def _conversation_identity() -> tuple[dict[str, str], dict[str, str], str]: from_wechat_id = _require_env("ROBOT_FROM_WX_ID") sender_wechat_id = os.environ.get("ROBOT_SENDER_WX_ID", "").strip() if from_wechat_id.endswith("@chatroom"): if not sender_wechat_id: raise ValueError("当前群聊缺少消息发送人微信 ID") target = {"wechat_id": from_wechat_id, "type": "chat_room"} creator = { "type": "chat_room", "wechat_id": sender_wechat_id, "chat_room_id": from_wechat_id, } return target, creator, "当前群聊" creator_wechat_id = sender_wechat_id or from_wechat_id target = {"wechat_id": from_wechat_id, "type": "friend"} creator = {"type": "friend", "wechat_id": creator_wechat_id, "chat_room_id": ""} return target, creator, "当前私聊" def _validate_clock(value: str) -> str: if not re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", value): raise ValueError("执行时间必须使用 HH:mm 格式,例如 08:30") return value def _parse_weekdays(values: list[str]) -> list[int]: parsed: list[int] = [] for raw in values: text = raw.strip() if not text: continue if text.startswith("["): try: items = json.loads(text) except json.JSONDecodeError as exc: raise ValueError(f"weekdays JSON 格式错误: {exc.msg}") from exc if not isinstance(items, list): raise ValueError("weekdays 必须是 JSON 数组或逗号分隔列表") else: items = [item.strip() for item in text.split(",") if item.strip()] for item in items: if isinstance(item, bool): raise ValueError("星期必须是 1 到 7 的整数") try: weekday = int(item) except (TypeError, ValueError) as exc: raise ValueError("星期必须是 1 到 7 的整数") from exc if weekday < 1 or weekday > 7: raise ValueError("星期必须在 1 到 7 之间,1 代表周一,7 代表周日") parsed.append(weekday) result = sorted(set(parsed)) if not result: raise ValueError("每周任务至少需要一个星期") return result def _parse_run_at(value: str, now: datetime) -> int: text = value.strip() parsed: datetime | None = None for pattern in ("%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S"): try: parsed = datetime.strptime(text, pattern).replace(tzinfo=SHANGHAI_TZ) break except ValueError: continue if parsed is None: raise ValueError("run-at 必须使用 YYYY-MM-DD HH:mm 或 YYYY-MM-DD HH:mm:ss 格式") delay_seconds = math.ceil((parsed - now).total_seconds()) if delay_seconds < 1: raise ValueError("一次性任务的执行时间必须晚于当前时间") if delay_seconds > MAX_DELAY_SECONDS: raise ValueError("一次性任务最多只能设置到未来 24 小时内") return delay_seconds def _delay_seconds(args: argparse.Namespace, now: datetime) -> int: if args.delay_seconds is not None: seconds = args.delay_seconds elif args.delay_minutes is not None: seconds = args.delay_minutes * 60 elif args.delay_hours is not None: seconds = args.delay_hours * 60 * 60 elif args.run_at is not None: seconds = _parse_run_at(args.run_at, now) else: raise ValueError( "一次性任务必须提供 delay-seconds、delay-minutes、delay-hours 或 run-at" ) if seconds < 1 or seconds > MAX_DELAY_SECONDS: raise ValueError("一次性任务延时必须在 1 秒到 24 小时之间") return seconds def _build_schedule_config(args: argparse.Namespace, now: datetime) -> dict[str, Any]: has_delay = any( value is not None for value in (args.delay_seconds, args.delay_minutes, args.delay_hours, args.run_at) ) weekday_values = [*args.weekday, *args.weekdays] if args.schedule_type == "delay_once": if args.time or weekday_values: raise ValueError("一次性任务不能同时设置 time 或 weekdays") return {"delay_seconds": _delay_seconds(args, now)} if has_delay: raise ValueError(f"{args.schedule_type} 任务不能设置延时参数") clock = _validate_clock(args.time.strip()) if args.schedule_type == "weekly": return {"time": clock, "weekdays": _parse_weekdays(weekday_values)} if weekday_values: raise ValueError(f"{args.schedule_type} 任务不能设置 weekdays") return {"time": clock} def _build_payload(args: argparse.Namespace) -> tuple[dict[str, Any], str]: name = args.name.strip() content = args.content.strip() ai_prompt = args.ai_prompt.strip() if not name: raise ValueError("任务名称不能为空") if len(name) > 100: raise ValueError("任务名称不能超过 100 个字符") if len(content) > 500: raise ValueError("固定提醒文本不能超过 500 个字符") if not content and not ai_prompt: raise ValueError("content 和 ai-prompt 至少需要提供一项") target, creator, target_label = _conversation_identity() schedule_config = _build_schedule_config(args, datetime.now(SHANGHAI_TZ)) payload: dict[str, Any] = { "name": name, "enabled": True, "schedule_type": args.schedule_type, "schedule_config": schedule_config, "targets": [target], "fixed_text": content, "images": [], "ai_prompt": ai_prompt, "creator": creator, } return payload, target_label def _post_json(url: str, payload: dict[str, Any], timeout: int = 30) -> dict[str, Any]: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") request = urllib.request.Request( url, data=body, headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=timeout) as response: response_text = response.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as exc: error_body = exc.read().decode("utf-8", errors="replace") raise RuntimeError(f"接口返回 HTTP {exc.code}: {error_body}") from exc except (urllib.error.URLError, TimeoutError, socket.timeout, ConnectionError) as exc: raise AmbiguousCreateError( f"请求定时任务接口失败,结果可能未知:{exc}。请先在定时任务列表核对,勿直接重试" ) from exc if not response_text.strip(): raise AmbiguousCreateError( "定时任务接口返回空响应,任务可能已创建。请先在定时任务列表核对,勿直接重试" ) try: result = json.loads(response_text) except json.JSONDecodeError as exc: raise AmbiguousCreateError( "定时任务接口返回了无效 JSON,任务可能已创建。请先在定时任务列表核对,勿直接重试" ) from exc if not isinstance(result, dict): raise AmbiguousCreateError( "定时任务接口响应不是 JSON 对象,任务可能已创建。请先在定时任务列表核对,勿直接重试" ) return result def _unwrap_api_response(response: dict[str, Any]) -> dict[str, Any]: if response.get("code") != 200: message = str(response.get("message") or "接口返回未知业务错误") if "任务已保存,但刷新调度器失败" in message: raise AmbiguousCreateError(f"{message}。任务可能已经入库,请先核对任务列表,勿直接重试") raise RuntimeError(message) data = response.get("data") if not isinstance(data, dict): raise AmbiguousCreateError( "接口成功响应中缺少任务数据,任务可能已创建。请先在定时任务列表核对,勿直接重试" ) return data def _format_next_run(timestamp: Any) -> str | None: if timestamp is None: return None try: value = int(timestamp) except (TypeError, ValueError): return None return datetime.fromtimestamp(value, SHANGHAI_TZ).strftime("%Y-%m-%d %H:%M:%S Asia/Shanghai") def _success_output(data: dict[str, Any], target_label: str) -> dict[str, Any]: targets = data.get("targets") first_target = targets[0] if isinstance(targets, list) and targets else {} if not isinstance(first_target, dict): first_target = {} return { "ok": True, "message": "定时任务创建成功", "task": { "id": data.get("id"), "name": data.get("name"), "schedule_type": data.get("schedule_type"), "schedule_summary": data.get("schedule_summary"), "next_run_at": data.get("next_run_at"), "next_run_time": _format_next_run(data.get("next_run_at")), "fixed_text": data.get("fixed_text"), "uses_ai_prompt": bool(data.get("ai_prompt")), "target_label": target_label, "target_type": first_target.get("type"), }, } def main(argv: list[str]) -> int: try: args = _parse_args(argv) payload, target_label = _build_payload(args) if args.dry_run: print( json.dumps( {"ok": True, "dry_run": True, "target_label": target_label, "payload": payload}, ensure_ascii=False, indent=2, ) ) return 0 client_port = _require_env("ROBOT_WECHAT_CLIENT_PORT") url = f"http://127.0.0.1:{client_port}/api/v1/robot/scheduled-tasks" response = _post_json(url, payload) data = _unwrap_api_response(response) print(json.dumps(_success_output(data, target_label), ensure_ascii=False, indent=2)) return 0 except (ValueError, RuntimeError) as exc: print(f"创建定时任务失败:{exc}") return 1 except Exception as exc: # Keep script failures visible to the agent without hiding them. print(f"创建定时任务失败:{type(exc).__name__}: {exc}") return 1 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))