wechat-robot-skills/skills/create-scheduled-task/scripts/create_scheduled_task.py
2026-07-19 01:06:01 +08:00

350 lines
13 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.

#!/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:]))