#!/usr/bin/env python3 """ Scheduled Task Manager CLI Tool Add, delete, modify, and query user scheduled tasks. Data is stored in tasks.yaml. Environment variables: ASSISTANT_ID: Current bot ID USER_IDENTIFIER: Current user identifier """ import argparse import os import sys import yaml import json import string import random from datetime import datetime, timezone, timedelta from pathlib import Path try: from croniter import croniter except ImportError: print("Error: croniter is required. Install with: pip install croniter", file=sys.stderr) sys.exit(1) # Language to timezone mapping LANGUAGE_TIMEZONE_MAP = { 'zh': 'Asia/Shanghai', 'ja': 'Asia/Tokyo', 'jp': 'Asia/Tokyo', 'en': 'UTC', } # Timezone to UTC offset (hours) TIMEZONE_OFFSET_MAP = { 'Asia/Shanghai': 8, 'Asia/Tokyo': 9, 'UTC': 0, 'America/New_York': -5, 'America/Los_Angeles': -8, 'Europe/London': 0, 'Europe/Berlin': 1, } def get_tasks_dir(bot_id: str, user_id: str) -> Path: """Get user task directory path""" return Path("users") / user_id def get_tasks_file(bot_id: str, user_id: str) -> Path: """Get tasks.yaml file path""" return get_tasks_dir(bot_id, user_id) / "tasks.yaml" def get_logs_dir(bot_id: str, user_id: str) -> Path: """Get task logs directory""" return get_tasks_dir(bot_id, user_id) / "task_logs" def load_tasks(bot_id: str, user_id: str) -> dict: """Load tasks.yaml""" tasks_file = get_tasks_file(bot_id, user_id) if not tasks_file.exists(): return {"tasks": []} with open(tasks_file, 'r', encoding='utf-8') as f: data = yaml.safe_load(f) return data if data and "tasks" in data else {"tasks": []} def save_tasks(bot_id: str, user_id: str, data: dict): """Save tasks.yaml""" tasks_file = get_tasks_file(bot_id, user_id) tasks_file.parent.mkdir(parents=True, exist_ok=True) with open(tasks_file, 'w', encoding='utf-8') as f: yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False) def generate_task_id() -> str: """Generate unique task ID""" ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") rand = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6)) return f"task_{ts}_{rand}" def parse_timezone_offset(tz: str) -> int: """Get UTC offset hours for a timezone""" return TIMEZONE_OFFSET_MAP.get(tz, 0) def compute_next_run_cron(schedule: str, tz: str, after: datetime = None) -> str: """ Calculate next execution UTC time based on cron expression and timezone. The cron expression is based on user's local time, so we first calculate the next trigger in local time, then convert to UTC. """ offset_hours = parse_timezone_offset(tz) offset = timedelta(hours=offset_hours) # Current UTC time now_utc = after or datetime.now(timezone.utc) # Convert to user's local time (naive) now_local = (now_utc + offset).replace(tzinfo=None) # Calculate next cron trigger in local time cron = croniter(schedule, now_local) next_local = cron.get_next(datetime) # Convert back to UTC next_utc = next_local - offset return next_utc.replace(tzinfo=timezone.utc).isoformat() def parse_scheduled_at(scheduled_at_str: str) -> str: """Parse one-time task time string, return UTC ISO format""" # Try to parse ISO format with timezone offset try: dt = datetime.fromisoformat(scheduled_at_str) if dt.tzinfo is None: # No timezone info, assume UTC dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc).isoformat() except ValueError: pass # Try to parse common formats for fmt in ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"]: try: dt = datetime.strptime(scheduled_at_str, fmt).replace(tzinfo=timezone.utc) return dt.isoformat() except ValueError: continue raise ValueError(f"Cannot parse time format: {scheduled_at_str}") def cmd_list(args, bot_id: str, user_id: str): """List all tasks""" data = load_tasks(bot_id, user_id) tasks = data.get("tasks", []) if not tasks: print("No scheduled tasks.") return if args.format == "brief": # Brief format for PrePrompt hook print(f"Scheduled tasks ({len(tasks)}):") for t in tasks: status_icon = {"active": "✅", "paused": "⏸️", "done": "✔️", "expired": "⏰"}.get(t["status"], "❓") if t["type"] == "cron": print(f" {status_icon} [{t['id']}] {t['name']} | cron: {t['schedule']} ({t.get('timezone', 'UTC')}) | executed {t.get('execution_count', 0)} times") else: print(f" {status_icon} [{t['id']}] {t['name']} | once: {t.get('scheduled_at', 'N/A')}") else: # Detail format for t in tasks: print(f"--- Task: {t['name']} ---") print(f" ID: {t['id']}") print(f" Type: {t['type']}") print(f" Status: {t['status']}") if t["type"] == "cron": print(f" Cron: {t['schedule']}") print(f" Timezone: {t.get('timezone', 'UTC')}") else: print(f" Scheduled at: {t.get('scheduled_at', 'N/A')}") print(f" Message: {t['message']}") print(f" Next run: {t.get('next_run_at', 'N/A')}") print(f" Last run: {t.get('last_executed_at', 'N/A')}") print(f" Executions: {t.get('execution_count', 0)}") print(f" Created at: {t.get('created_at', 'N/A')}") print() def cmd_add(args, bot_id: str, user_id: str): """Add a task""" data = load_tasks(bot_id, user_id) task_id = generate_task_id() now_utc = datetime.now(timezone.utc).isoformat() task = { "id": task_id, "name": args.name, "type": args.type, "schedule": None, "scheduled_at": None, "timezone": args.timezone or "UTC", "message": args.message, "status": "active", "created_at": now_utc, "last_executed_at": None, "next_run_at": None, "execution_count": 0, } if args.type == "cron": if not args.schedule: print("Error: --schedule is required for cron type tasks", file=sys.stderr) sys.exit(1) # Validate cron expression try: croniter(args.schedule) except (ValueError, KeyError) as e: print(f"Error: Invalid cron expression '{args.schedule}': {e}", file=sys.stderr) sys.exit(1) task["schedule"] = args.schedule task["next_run_at"] = compute_next_run_cron(args.schedule, task["timezone"]) elif args.type == "once": if not args.scheduled_at: print("Error: --scheduled-at is required for once type tasks", file=sys.stderr) sys.exit(1) try: utc_time = parse_scheduled_at(args.scheduled_at) except ValueError as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) task["scheduled_at"] = utc_time task["next_run_at"] = utc_time data["tasks"].append(task) save_tasks(bot_id, user_id, data) print(f"Task created: {task_id}") print(f" Name: {args.name}") print(f" Type: {args.type}") print(f" Next run (UTC): {task['next_run_at']}") def cmd_edit(args, bot_id: str, user_id: str): """Edit a task""" data = load_tasks(bot_id, user_id) task = None for t in data["tasks"]: if t["id"] == args.task_id: task = t break if not task: print(f"Error: Task {args.task_id} not found", file=sys.stderr) sys.exit(1) if args.name: task["name"] = args.name if args.message: task["message"] = args.message if args.schedule: try: croniter(args.schedule) except (ValueError, KeyError) as e: print(f"Error: Invalid cron expression '{args.schedule}': {e}", file=sys.stderr) sys.exit(1) task["schedule"] = args.schedule if args.timezone: task["timezone"] = args.timezone if args.scheduled_at: try: task["scheduled_at"] = parse_scheduled_at(args.scheduled_at) task["next_run_at"] = task["scheduled_at"] except ValueError as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) # Recalculate next_run_at (if cron and schedule or timezone changed) if task["type"] == "cron" and task.get("schedule"): task["next_run_at"] = compute_next_run_cron(task["schedule"], task.get("timezone", "UTC")) save_tasks(bot_id, user_id, data) print(f"Task updated: {args.task_id}") def cmd_delete(args, bot_id: str, user_id: str): """Delete a task""" data = load_tasks(bot_id, user_id) original_count = len(data["tasks"]) data["tasks"] = [t for t in data["tasks"] if t["id"] != args.task_id] if len(data["tasks"]) == original_count: print(f"Error: Task {args.task_id} not found", file=sys.stderr) sys.exit(1) save_tasks(bot_id, user_id, data) print(f"Task deleted: {args.task_id}") def cmd_toggle(args, bot_id: str, user_id: str): """Pause/resume task""" data = load_tasks(bot_id, user_id) for t in data["tasks"]: if t["id"] == args.task_id: if t["status"] == "active": t["status"] = "paused" print(f"Task paused: {args.task_id}") elif t["status"] == "paused": t["status"] = "active" # Recalculate next_run_at when resuming if t["type"] == "cron" and t.get("schedule"): t["next_run_at"] = compute_next_run_cron(t["schedule"], t.get("timezone", "UTC")) print(f"Task resumed: {args.task_id}") else: print(f"Task status is {t['status']}, cannot toggle", file=sys.stderr) sys.exit(1) save_tasks(bot_id, user_id, data) return print(f"Error: Task {args.task_id} not found", file=sys.stderr) sys.exit(1) def cmd_logs(args, bot_id: str, user_id: str): """View execution logs""" logs_dir = get_logs_dir(bot_id, user_id) log_file = logs_dir / "execution.log" if not log_file.exists(): print("No execution logs yet.") return with open(log_file, 'r', encoding='utf-8') as f: logs = yaml.safe_load(f) if not logs: print("No execution logs yet.") return # Filter by task ID if args.task_id: logs = [l for l in logs if l.get("task_id") == args.task_id] # Limit count limit = args.limit or 10 logs = logs[-limit:] if not logs: print("No matching logs found.") return for log in logs: status_icon = "✅" if log.get("status") == "success" else "❌" print(f"{status_icon} [{log.get('executed_at', 'N/A')}] {log.get('task_name', 'N/A')}") response = log.get("response", "") # Truncate long responses if len(response) > 200: response = response[:200] + "..." print(f" {response}") if log.get("duration_ms"): print(f" Duration: {log['duration_ms']}ms") print() def main(): bot_id = os.getenv("ASSISTANT_ID", "") user_id = os.getenv("USER_IDENTIFIER", "") if not bot_id or not user_id: print("Error: ASSISTANT_ID and USER_IDENTIFIER environment variables must be set", file=sys.stderr) sys.exit(1) parser = argparse.ArgumentParser(description="Scheduled task manager") subparsers = parser.add_subparsers(dest="command", help="Available commands") # list p_list = subparsers.add_parser("list", help="List all tasks") p_list.add_argument("--format", choices=["brief", "detail"], default="detail", help="Output format") # add p_add = subparsers.add_parser("add", help="Add a task") p_add.add_argument("--name", required=True, help="Task name") p_add.add_argument("--type", required=True, choices=["cron", "once"], help="Task type") p_add.add_argument("--schedule", help="Cron expression (required for cron type)") p_add.add_argument("--scheduled-at", help="Execution time in ISO 8601 format (required for once type)") p_add.add_argument("--timezone", help="Timezone (e.g. Asia/Tokyo), default UTC") p_add.add_argument("--message", required=True, help="Message content to send to AI") # edit p_edit = subparsers.add_parser("edit", help="Edit a task") p_edit.add_argument("task_id", help="Task ID") p_edit.add_argument("--name", help="New task name") p_edit.add_argument("--schedule", help="New cron expression") p_edit.add_argument("--scheduled-at", help="New execution time") p_edit.add_argument("--timezone", help="New timezone") p_edit.add_argument("--message", help="New message content") # delete p_delete = subparsers.add_parser("delete", help="Delete a task") p_delete.add_argument("task_id", help="Task ID") # toggle p_toggle = subparsers.add_parser("toggle", help="Pause/resume a task") p_toggle.add_argument("task_id", help="Task ID") # logs p_logs = subparsers.add_parser("logs", help="View execution logs") p_logs.add_argument("--task-id", help="Filter by task ID") p_logs.add_argument("--limit", type=int, default=10, help="Number of entries to show") args = parser.parse_args() if not args.command: parser.print_help() sys.exit(1) commands = { "list": cmd_list, "add": cmd_add, "edit": cmd_edit, "delete": cmd_delete, "toggle": cmd_toggle, "logs": cmd_logs, } commands[args.command](args, bot_id, user_id) if __name__ == "__main__": main()