Files
derp/plugins/cron.py
user 073659607e feat: add multi-server support
Connect to multiple IRC servers concurrently from a single config file.
Plugins are loaded once and shared; per-server state is isolated via
separate SQLite databases and per-bot runtime state (bot._pstate).

- Add build_server_configs() for [servers.*] config layout
- Bot.__init__ gains name parameter, _pstate dict for plugin isolation
- cli.py runs multiple bots via asyncio.gather
- 9 stateful plugins migrated from module-level dicts to _ps(bot) pattern
- Backward compatible: legacy [server] config works unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:04:20 +01:00

297 lines
8.8 KiB
Python

"""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 <interval> <#channel> <command...> Schedule a command
!cron del <id> Remove a job
!cron list List jobs
"""
parts = message.text.split(None, 4)
if len(parts) < 2:
await bot.reply(message, "Usage: !cron <add|del|list> [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 <id>")
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 <interval> <#channel> <command...>",
)
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 <add|del|list> [args]")