wechat-robot-skills/skills/create-scheduled-task/scripts/create_scheduled_task.py

566 lines
21 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.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from typing import Any, Literal, NoReturn, TypedDict
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
MENTION_ALL_WECHAT_ID = "notify@all"
class ScheduledTaskTarget(TypedDict):
wechat_id: str
type: Literal["chat_room", "friend"]
mention_wechat_ids: list[str]
class ScheduledTaskCreator(TypedDict):
type: Literal["chat_room", "friend"]
wechat_id: str
chat_room_id: str
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=[])
parser.add_argument("--mention", action="append", default=[])
parser.add_argument("--mentions", action="append", default=[])
parser.add_argument("--mention-all", "--all", dest="mention_all", action="store_true")
parser.add_argument("--no-mention", action="store_true")
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[ScheduledTaskTarget, ScheduledTaskCreator, 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: ScheduledTaskTarget = {
"wechat_id": from_wechat_id,
"type": "chat_room",
"mention_wechat_ids": [],
}
creator: ScheduledTaskCreator = {
"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",
"mention_wechat_ids": [],
}
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_mentions(mention_values: list[str], mentions_values: list[str]) -> list[str]:
parsed = [value.strip() for value in mention_values if value.strip()]
for raw in mentions_values:
text = raw.strip()
if not text:
continue
try:
items = json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError(f"mentions JSON 格式错误: {exc.msg}") from exc
if not isinstance(items, list) or any(not isinstance(item, str) for item in items):
raise ValueError("mentions 必须是字符串 JSON 数组")
parsed.extend(item.strip() for item in items if item.strip())
result: list[str] = []
seen: set[str] = set()
for mention in parsed:
key = mention.casefold()
if key not in seen:
seen.add(key)
result.append(mention)
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 _member_text(member: dict[str, Any], field: str) -> str:
value = member.get(field)
return str(value).strip() if value is not None else ""
def _member_candidates(data: Any, chat_room_id: str) -> list[dict[str, Any]]:
if not isinstance(data, list):
raise RuntimeError("群成员查询接口返回的数据不是数组")
candidates: list[dict[str, Any]] = []
for item in data:
if not isinstance(item, dict):
continue
if _member_text(item, "chat_room_id") != chat_room_id:
continue
if item.get("is_leaved") not in (None, False, 0):
continue
if _member_text(item, "wechat_id"):
candidates.append(item)
return candidates
def _describe_member(member: dict[str, Any]) -> str:
remark = _member_text(member, "remark")
nickname = _member_text(member, "nickname")
if remark and nickname and remark != nickname:
return f"{remark}(昵称:{nickname}"
return remark or nickname or _member_text(member, "wechat_id")
def _pick_unique_member(
mention: str,
candidates: list[dict[str, Any]],
) -> dict[str, Any]:
keyword = mention.casefold()
match_groups = [
[item for item in candidates if _member_text(item, "remark").casefold() == keyword],
[item for item in candidates if _member_text(item, "nickname").casefold() == keyword],
[item for item in candidates if keyword in _member_text(item, "remark").casefold()],
[item for item in candidates if keyword in _member_text(item, "nickname").casefold()],
]
for matches in match_groups:
unique: dict[str, dict[str, Any]] = {}
for item in matches:
unique.setdefault(_member_text(item, "wechat_id"), item)
if len(unique) == 1:
return next(iter(unique.values()))
if len(unique) > 1:
labels = "".join(_describe_member(item) for item in list(unique.values())[:5])
raise ValueError(
f"群成员“{mention}”匹配到多人({labels}),请使用唯一的完整群备注或昵称"
)
raise ValueError(f"未找到当前群内未退群成员:“{mention}")
def _get_json(url: str, timeout: int = 15) -> dict[str, Any]:
request = urllib.request.Request(url, method="GET")
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 RuntimeError(f"查询群成员失败:{exc}") from exc
if not response_text.strip():
raise RuntimeError("群成员查询接口返回空响应")
try:
result = json.loads(response_text)
except json.JSONDecodeError as exc:
raise RuntimeError("群成员查询接口返回了无效 JSON") from exc
if not isinstance(result, dict):
raise RuntimeError("群成员查询接口响应不是 JSON 对象")
return result
def _resolve_mentions(
client_port: str,
chat_room_id: str,
mentions: list[str],
) -> list[dict[str, str]]:
resolved: list[dict[str, str]] = []
seen_wechat_ids: set[str] = set()
for mention in mentions:
query = urllib.parse.urlencode({"chat_room_id": chat_room_id, "keyword": mention})
url = (
f"http://127.0.0.1:{client_port}"
f"/api/v1/robot/chat-room/not-left-members?{query}"
)
response = _get_json(url)
if response.get("code") != 200:
raise RuntimeError(str(response.get("message") or "查询群成员失败"))
member = _pick_unique_member(
mention,
_member_candidates(response.get("data"), chat_room_id),
)
wechat_id = _member_text(member, "wechat_id")
if wechat_id in seen_wechat_ids:
continue
seen_wechat_ids.add(wechat_id)
resolved.append(
{
"query": mention,
"wechat_id": wechat_id,
"display_name": _member_text(member, "remark")
or _member_text(member, "nickname")
or mention,
}
)
return resolved
def _build_payload(
args: argparse.Namespace,
) -> tuple[dict[str, Any], str, dict[str, Any]]:
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()
mentions = _parse_mentions(args.mention, args.mentions)
mention_mode_count = int(bool(mentions)) + int(args.mention_all) + int(args.no_mention)
if mention_mode_count > 1:
raise ValueError("mention-all、no-mention 和 mention/mentions 不能同时使用")
mention_summary: dict[str, Any]
if args.mention_all:
if target["type"] != "chat_room":
raise ValueError("当前会话不是群聊,不能在定时任务中艾特所有人")
target["mention_wechat_ids"] = [MENTION_ALL_WECHAT_ID]
mention_summary = {"mode": "all", "display_names": ["所有人"]}
elif mentions:
if target["type"] != "chat_room":
raise ValueError("当前会话不是群聊,不能在定时任务中艾特群成员")
client_port = _require_env("ROBOT_WECHAT_CLIENT_PORT")
resolved_mentions = _resolve_mentions(client_port, target["wechat_id"], mentions)
target["mention_wechat_ids"] = [item["wechat_id"] for item in resolved_mentions]
mention_summary = {
"mode": "custom",
"display_names": [item["display_name"] for item in resolved_mentions],
}
elif args.no_mention:
target["mention_wechat_ids"] = []
mention_summary = {"mode": "none", "display_names": []}
elif target["type"] == "chat_room":
target["mention_wechat_ids"] = [creator["wechat_id"]]
mention_summary = {"mode": "creator", "display_names": []}
else:
target["mention_wechat_ids"] = []
mention_summary = {"mode": "none", "display_names": []}
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, mention_summary
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,
mention_summary: dict[str, Any],
) -> 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"),
"mention": mention_summary,
},
}
def main(argv: list[str]) -> int:
try:
args = _parse_args(argv)
payload, target_label, mention_summary = _build_payload(args)
if args.dry_run:
print(
json.dumps(
{
"ok": True,
"dry_run": True,
"target_label": target_label,
"mention": mention_summary,
"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, mention_summary),
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:]))