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>
297 lines
8.8 KiB
Python
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]")
|