"""Plugin: scheduled command execution on a timer.""" from __future__ import annotations import asyncio import hashlib import json import re import time from derp.irc import Message from derp.plugin import command, event # -- Constants --------------------------------------------------------------- _DURATION_RE = re.compile(r"(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$") _MIN_INTERVAL = 60 _MAX_INTERVAL = 604800 # 7 days _MAX_JOBS = 20 # -- Per-bot plugin runtime state -------------------------------------------- def _ps(bot): """Per-bot plugin runtime state.""" return bot._pstate.setdefault("cron", { "jobs": {}, "tasks": {}, }) # -- Pure helpers ------------------------------------------------------------ def _make_id(channel: str, cmd: str) -> str: """Generate a short hex ID from channel + command + timestamp.""" raw = f"{channel}:{cmd}:{time.monotonic()}".encode() return hashlib.sha256(raw).hexdigest()[:6] def _parse_duration(spec: str) -> int | None: """Parse a duration like '5m', '1h30m', '2d', '90s', or raw seconds.""" try: secs = int(spec) return secs if secs > 0 else None except ValueError: pass m = _DURATION_RE.match(spec.lower()) if not m or not any(m.groups()): return None days = int(m.group(1) or 0) hours = int(m.group(2) or 0) mins = int(m.group(3) or 0) secs = int(m.group(4) or 0) total = days * 86400 + hours * 3600 + mins * 60 + secs return total if total > 0 else None def _format_duration(secs: int) -> str: """Format seconds into compact duration.""" parts = [] if secs >= 86400: parts.append(f"{secs // 86400}d") secs %= 86400 if secs >= 3600: parts.append(f"{secs // 3600}h") secs %= 3600 if secs >= 60: parts.append(f"{secs // 60}m") secs %= 60 if secs or not parts: parts.append(f"{secs}s") return "".join(parts) # -- State helpers ----------------------------------------------------------- def _state_key(channel: str, cron_id: str) -> str: """Build composite state key.""" return f"{channel}:{cron_id}" def _save(bot, key: str, data: dict) -> None: """Persist cron job to bot.state.""" bot.state.set("cron", key, json.dumps(data)) def _load(bot, key: str) -> dict | None: """Load cron job from bot.state.""" raw = bot.state.get("cron", key) if raw is None: return None try: return json.loads(raw) except json.JSONDecodeError: return None def _delete(bot, key: str) -> None: """Remove cron job from bot.state.""" bot.state.delete("cron", key) # -- Cron loop --------------------------------------------------------------- async def _cron_loop(bot, key: str) -> None: """Repeating loop: sleep, then dispatch the stored command.""" try: while True: data = _ps(bot)["jobs"].get(key) if not data: return await asyncio.sleep(data["interval"]) # Synthesize a message for command dispatch msg = Message( raw="", prefix=data["prefix"], nick=data["nick"], command="PRIVMSG", params=[data["channel"], data["command"]], tags={}, ) bot._dispatch_command(msg) except asyncio.CancelledError: pass def _start_job(bot, key: str) -> None: """Create and track a cron task.""" ps = _ps(bot) existing = ps["tasks"].get(key) if existing and not existing.done(): return task = asyncio.create_task(_cron_loop(bot, key)) ps["tasks"][key] = task def _stop_job(bot, key: str) -> None: """Cancel and remove a cron task.""" ps = _ps(bot) task = ps["tasks"].pop(key, None) if task and not task.done(): task.cancel() ps["jobs"].pop(key, None) # -- Restore on connect ----------------------------------------------------- def _restore(bot) -> None: """Rebuild cron tasks from persisted state.""" ps = _ps(bot) for key in bot.state.keys("cron"): existing = ps["tasks"].get(key) if existing and not existing.done(): continue data = _load(bot, key) if data is None: continue ps["jobs"][key] = data _start_job(bot, key) @event("001") async def on_connect(bot, message): """Restore cron jobs on connect.""" _restore(bot) # -- Command handler --------------------------------------------------------- @command("cron", help="Cron: !cron add|del|list", admin=True) async def cmd_cron(bot, message): """Scheduled command execution on a timer. Usage: !cron add <#channel> Schedule a command !cron del Remove a job !cron list List jobs """ parts = message.text.split(None, 4) if len(parts) < 2: await bot.reply(message, "Usage: !cron [args]") return sub = parts[1].lower() # -- list ---------------------------------------------------------------- if sub == "list": if not message.is_channel: await bot.reply(message, "Use this command in a channel") return channel = message.target prefix = f"{channel}:" entries = [] for key in bot.state.keys("cron"): if key.startswith(prefix): data = _load(bot, key) if data: cron_id = data["id"] interval = _format_duration(data["interval"]) cmd = data["command"] entries.append(f"#{cron_id} every {interval}: {cmd}") if not entries: await bot.reply(message, "No cron jobs in this channel") return for entry in entries: await bot.reply(message, entry) return # -- del ----------------------------------------------------------------- if sub == "del": if len(parts) < 3: await bot.reply(message, "Usage: !cron del ") return cron_id = parts[2].lstrip("#") # Find matching key across all channels found_key = None for key in bot.state.keys("cron"): data = _load(bot, key) if data and data["id"] == cron_id: found_key = key break if not found_key: await bot.reply(message, f"No cron job #{cron_id}") return _stop_job(bot, found_key) _delete(bot, found_key) await bot.reply(message, f"Removed cron #{cron_id}") return # -- add ----------------------------------------------------------------- if sub == "add": if not message.is_channel: await bot.reply(message, "Use this command in a channel") return if len(parts) < 5: await bot.reply( message, "Usage: !cron add <#channel> ", ) return interval_spec = parts[2] target_channel = parts[3] cmd_text = parts[4] if not target_channel.startswith(("#", "&")): await bot.reply(message, "Target must be a channel (e.g. #ops)") return interval = _parse_duration(interval_spec) if interval is None: await bot.reply(message, "Invalid interval (use: 5m, 1h, 2d)") return if interval < _MIN_INTERVAL: await bot.reply( message, f"Minimum interval is {_format_duration(_MIN_INTERVAL)}", ) return if interval > _MAX_INTERVAL: await bot.reply( message, f"Maximum interval is {_format_duration(_MAX_INTERVAL)}", ) return # Check per-channel limit ch_prefix = f"{target_channel}:" count = sum( 1 for k in bot.state.keys("cron") if k.startswith(ch_prefix) ) if count >= _MAX_JOBS: await bot.reply(message, f"Job limit reached ({_MAX_JOBS})") return cron_id = _make_id(target_channel, cmd_text) key = _state_key(target_channel, cron_id) data = { "id": cron_id, "channel": target_channel, "command": cmd_text, "interval": interval, "prefix": message.prefix, "nick": message.nick, "added_by": message.nick, } _save(bot, key, data) _ps(bot)["jobs"][key] = data _start_job(bot, key) fmt_interval = _format_duration(interval) await bot.reply( message, f"Cron #{cron_id}: {cmd_text} every {fmt_interval} in {target_channel}", ) return await bot.reply(message, "Usage: !cron [args]")