feat: add cron plugin for scheduled commands
Admin-only plugin for interval-based command execution. Supports add/del/list, 1m-7d intervals, 20 jobs/channel. Persists via bot.state, restores on reconnect. Includes test_cron.py (~38 cases). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
288
plugins/cron.py
Normal file
288
plugins/cron.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
# -- Module-level tracking ---------------------------------------------------
|
||||||
|
|
||||||
|
_jobs: dict[str, dict] = {}
|
||||||
|
_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# -- 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 = _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."""
|
||||||
|
existing = _tasks.get(key)
|
||||||
|
if existing and not existing.done():
|
||||||
|
return
|
||||||
|
task = asyncio.create_task(_cron_loop(bot, key))
|
||||||
|
_tasks[key] = task
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_job(key: str) -> None:
|
||||||
|
"""Cancel and remove a cron task."""
|
||||||
|
task = _tasks.pop(key, None)
|
||||||
|
if task and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
_jobs.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Restore on connect -----------------------------------------------------
|
||||||
|
|
||||||
|
def _restore(bot) -> None:
|
||||||
|
"""Rebuild cron tasks from persisted state."""
|
||||||
|
for key in bot.state.keys("cron"):
|
||||||
|
existing = _tasks.get(key)
|
||||||
|
if existing and not existing.done():
|
||||||
|
continue
|
||||||
|
data = _load(bot, key)
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
_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(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)
|
||||||
|
_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]")
|
||||||
639
tests/test_cron.py
Normal file
639
tests/test_cron.py
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
"""Tests for the cron plugin."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from derp.irc import Message
|
||||||
|
|
||||||
|
# plugins/ is not a Python package -- load the module from file path
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"plugins.cron", Path(__file__).resolve().parent.parent / "plugins" / "cron.py",
|
||||||
|
)
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules[_spec.name] = _mod
|
||||||
|
_spec.loader.exec_module(_mod)
|
||||||
|
|
||||||
|
from plugins.cron import ( # noqa: E402
|
||||||
|
_MAX_JOBS,
|
||||||
|
_delete,
|
||||||
|
_format_duration,
|
||||||
|
_jobs,
|
||||||
|
_load,
|
||||||
|
_make_id,
|
||||||
|
_parse_duration,
|
||||||
|
_restore,
|
||||||
|
_save,
|
||||||
|
_start_job,
|
||||||
|
_state_key,
|
||||||
|
_stop_job,
|
||||||
|
_tasks,
|
||||||
|
cmd_cron,
|
||||||
|
on_connect,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
class _FakeState:
|
||||||
|
"""In-memory stand-in for bot.state."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._store: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
def get(self, plugin: str, key: str, default: str | None = None) -> str | None:
|
||||||
|
return self._store.get(plugin, {}).get(key, default)
|
||||||
|
|
||||||
|
def set(self, plugin: str, key: str, value: str) -> None:
|
||||||
|
self._store.setdefault(plugin, {})[key] = value
|
||||||
|
|
||||||
|
def delete(self, plugin: str, key: str) -> bool:
|
||||||
|
try:
|
||||||
|
del self._store[plugin][key]
|
||||||
|
return True
|
||||||
|
except KeyError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def keys(self, plugin: str) -> list[str]:
|
||||||
|
return sorted(self._store.get(plugin, {}).keys())
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBot:
|
||||||
|
"""Minimal bot stand-in that captures sent/replied messages."""
|
||||||
|
|
||||||
|
def __init__(self, *, admin: bool = True):
|
||||||
|
self.sent: list[tuple[str, str]] = []
|
||||||
|
self.replied: list[str] = []
|
||||||
|
self.dispatched: list[Message] = []
|
||||||
|
self.state = _FakeState()
|
||||||
|
self._admin = admin
|
||||||
|
self.prefix = "!"
|
||||||
|
|
||||||
|
async def send(self, target: str, text: str) -> None:
|
||||||
|
self.sent.append((target, text))
|
||||||
|
|
||||||
|
async def reply(self, message, text: str) -> None:
|
||||||
|
self.replied.append(text)
|
||||||
|
|
||||||
|
def _is_admin(self, message) -> bool:
|
||||||
|
return self._admin
|
||||||
|
|
||||||
|
def _dispatch_command(self, msg: Message) -> None:
|
||||||
|
self.dispatched.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _msg(text: str, nick: str = "admin", target: str = "#test") -> Message:
|
||||||
|
"""Create a channel PRIVMSG."""
|
||||||
|
return Message(
|
||||||
|
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
|
||||||
|
command="PRIVMSG", params=[target, text], tags={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pm(text: str, nick: str = "admin") -> Message:
|
||||||
|
"""Create a private PRIVMSG."""
|
||||||
|
return Message(
|
||||||
|
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
|
||||||
|
command="PRIVMSG", params=["botname", text], tags={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear() -> None:
|
||||||
|
"""Reset module-level state between tests."""
|
||||||
|
for task in _tasks.values():
|
||||||
|
if task and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
_tasks.clear()
|
||||||
|
_jobs.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestParseDuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestParseDuration:
|
||||||
|
def test_minutes(self):
|
||||||
|
assert _parse_duration("5m") == 300
|
||||||
|
|
||||||
|
def test_hours(self):
|
||||||
|
assert _parse_duration("1h") == 3600
|
||||||
|
|
||||||
|
def test_combined(self):
|
||||||
|
assert _parse_duration("1h30m") == 5400
|
||||||
|
|
||||||
|
def test_days(self):
|
||||||
|
assert _parse_duration("2d") == 172800
|
||||||
|
|
||||||
|
def test_seconds(self):
|
||||||
|
assert _parse_duration("90s") == 90
|
||||||
|
|
||||||
|
def test_raw_int(self):
|
||||||
|
assert _parse_duration("300") == 300
|
||||||
|
|
||||||
|
def test_zero(self):
|
||||||
|
assert _parse_duration("0") is None
|
||||||
|
|
||||||
|
def test_negative(self):
|
||||||
|
assert _parse_duration("-5") is None
|
||||||
|
|
||||||
|
def test_invalid(self):
|
||||||
|
assert _parse_duration("abc") is None
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert _parse_duration("") is None
|
||||||
|
|
||||||
|
def test_full_combo(self):
|
||||||
|
assert _parse_duration("1d2h3m4s") == 86400 + 7200 + 180 + 4
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestFormatDuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestFormatDuration:
|
||||||
|
def test_seconds(self):
|
||||||
|
assert _format_duration(45) == "45s"
|
||||||
|
|
||||||
|
def test_minutes(self):
|
||||||
|
assert _format_duration(300) == "5m"
|
||||||
|
|
||||||
|
def test_hours(self):
|
||||||
|
assert _format_duration(3600) == "1h"
|
||||||
|
|
||||||
|
def test_combined(self):
|
||||||
|
assert _format_duration(5400) == "1h30m"
|
||||||
|
|
||||||
|
def test_days(self):
|
||||||
|
assert _format_duration(86400) == "1d"
|
||||||
|
|
||||||
|
def test_zero(self):
|
||||||
|
assert _format_duration(0) == "0s"
|
||||||
|
|
||||||
|
def test_full_combo(self):
|
||||||
|
assert _format_duration(90061) == "1d1h1m1s"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestMakeId
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMakeId:
|
||||||
|
def test_returns_hex(self):
|
||||||
|
result = _make_id("#test", "!ping")
|
||||||
|
assert len(result) == 6
|
||||||
|
int(result, 16) # should not raise
|
||||||
|
|
||||||
|
def test_unique(self):
|
||||||
|
ids = {_make_id("#test", f"!cmd{i}") for i in range(10)}
|
||||||
|
assert len(ids) == 10
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestStateHelpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestStateHelpers:
|
||||||
|
def test_save_and_load(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
data = {"id": "abc123", "channel": "#test"}
|
||||||
|
_save(bot, "#test:abc123", data)
|
||||||
|
loaded = _load(bot, "#test:abc123")
|
||||||
|
assert loaded == data
|
||||||
|
|
||||||
|
def test_load_missing(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
assert _load(bot, "nonexistent") is None
|
||||||
|
|
||||||
|
def test_delete(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
_save(bot, "#test:del", {"id": "del"})
|
||||||
|
_delete(bot, "#test:del")
|
||||||
|
assert _load(bot, "#test:del") is None
|
||||||
|
|
||||||
|
def test_state_key(self):
|
||||||
|
assert _state_key("#ops", "abc123") == "#ops:abc123"
|
||||||
|
|
||||||
|
def test_load_invalid_json(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.state.set("cron", "bad", "not json{{{")
|
||||||
|
assert _load(bot, "bad") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCmdCronAdd
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCmdCronAdd:
|
||||||
|
def test_add_success(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
await cmd_cron(bot, _msg("!cron add 5m #ops !rss check news"))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert len(bot.replied) == 1
|
||||||
|
assert "Cron #" in bot.replied[0]
|
||||||
|
assert "!rss check news" in bot.replied[0]
|
||||||
|
assert "5m" in bot.replied[0]
|
||||||
|
assert "#ops" in bot.replied[0]
|
||||||
|
# Verify state persisted
|
||||||
|
keys = bot.state.keys("cron")
|
||||||
|
assert len(keys) == 1
|
||||||
|
data = json.loads(bot.state.get("cron", keys[0]))
|
||||||
|
assert data["command"] == "!rss check news"
|
||||||
|
assert data["interval"] == 300
|
||||||
|
assert data["channel"] == "#ops"
|
||||||
|
# Verify task started
|
||||||
|
assert len(_tasks) == 1
|
||||||
|
_clear()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
def test_add_requires_channel(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_cron(bot, _pm("!cron add 5m #ops !ping")))
|
||||||
|
assert "Use this command in a channel" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_add_missing_args(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_cron(bot, _msg("!cron add 5m")))
|
||||||
|
assert "Usage:" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_add_invalid_interval(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_cron(bot, _msg("!cron add abc #ops !ping")))
|
||||||
|
assert "Invalid interval" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_add_interval_too_short(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_cron(bot, _msg("!cron add 30s #ops !ping")))
|
||||||
|
assert "Minimum interval" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_add_interval_too_long(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_cron(bot, _msg("!cron add 30d #ops !ping")))
|
||||||
|
assert "Maximum interval" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_add_bad_target(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_cron(bot, _msg("!cron add 5m alice !ping")))
|
||||||
|
assert "Target must be a channel" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_add_job_limit(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
for i in range(_MAX_JOBS):
|
||||||
|
_save(bot, f"#ops:job{i}", {"id": f"job{i}", "channel": "#ops"})
|
||||||
|
|
||||||
|
asyncio.run(cmd_cron(bot, _msg("!cron add 5m #ops !ping")))
|
||||||
|
assert "limit reached" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_add_admin_required(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot(admin=False)
|
||||||
|
asyncio.run(cmd_cron(bot, _msg("!cron add 5m #ops !ping")))
|
||||||
|
# The @command(admin=True) decorator handles this via bot._dispatch_command,
|
||||||
|
# but since we call cmd_cron directly, the check is at the decorator level.
|
||||||
|
# In direct call tests, admin check is already handled by the framework.
|
||||||
|
# This test just verifies the command runs without error for non-admin.
|
||||||
|
# Framework-level denial is tested in integration tests.
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCmdCronDel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCmdCronDel:
|
||||||
|
def test_del_success(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
await cmd_cron(bot, _msg("!cron add 5m #test !ping"))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
# Extract ID from reply
|
||||||
|
reply = bot.replied[0]
|
||||||
|
cron_id = reply.split("#")[1].split(":")[0]
|
||||||
|
bot.replied.clear()
|
||||||
|
await cmd_cron(bot, _msg(f"!cron del {cron_id}"))
|
||||||
|
assert "Removed" in bot.replied[0]
|
||||||
|
assert cron_id in bot.replied[0]
|
||||||
|
assert len(bot.state.keys("cron")) == 0
|
||||||
|
_clear()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
def test_del_nonexistent(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_cron(bot, _msg("!cron del nosuch")))
|
||||||
|
assert "No cron job" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_del_missing_id(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_cron(bot, _msg("!cron del")))
|
||||||
|
assert "Usage:" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_del_with_hash_prefix(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
await cmd_cron(bot, _msg("!cron add 5m #test !ping"))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
reply = bot.replied[0]
|
||||||
|
cron_id = reply.split("#")[1].split(":")[0]
|
||||||
|
bot.replied.clear()
|
||||||
|
await cmd_cron(bot, _msg(f"!cron del #{cron_id}"))
|
||||||
|
assert "Removed" in bot.replied[0]
|
||||||
|
_clear()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCmdCronList
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCmdCronList:
|
||||||
|
def test_list_empty(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_cron(bot, _msg("!cron list")))
|
||||||
|
assert "No cron jobs" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_list_populated(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
_save(bot, "#test:abc123", {
|
||||||
|
"id": "abc123", "channel": "#test",
|
||||||
|
"command": "!rss check news", "interval": 300,
|
||||||
|
})
|
||||||
|
asyncio.run(cmd_cron(bot, _msg("!cron list")))
|
||||||
|
assert "#abc123" in bot.replied[0]
|
||||||
|
assert "5m" in bot.replied[0]
|
||||||
|
assert "!rss check news" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_list_requires_channel(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_cron(bot, _pm("!cron list")))
|
||||||
|
assert "Use this command in a channel" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_list_channel_isolation(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
_save(bot, "#test:mine", {
|
||||||
|
"id": "mine", "channel": "#test",
|
||||||
|
"command": "!ping", "interval": 300,
|
||||||
|
})
|
||||||
|
_save(bot, "#other:theirs", {
|
||||||
|
"id": "theirs", "channel": "#other",
|
||||||
|
"command": "!ping", "interval": 600,
|
||||||
|
})
|
||||||
|
asyncio.run(cmd_cron(bot, _msg("!cron list")))
|
||||||
|
assert "mine" in bot.replied[0]
|
||||||
|
assert len(bot.replied) == 1 # only the #test job
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCmdCronUsage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCmdCronUsage:
|
||||||
|
def test_no_args(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_cron(bot, _msg("!cron")))
|
||||||
|
assert "Usage:" in bot.replied[0]
|
||||||
|
|
||||||
|
def test_unknown_subcommand(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
asyncio.run(cmd_cron(bot, _msg("!cron foobar")))
|
||||||
|
assert "Usage:" in bot.replied[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestRestore
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRestore:
|
||||||
|
def test_restore_spawns_tasks(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
data = {
|
||||||
|
"id": "abc123", "channel": "#test",
|
||||||
|
"command": "!ping", "interval": 300,
|
||||||
|
"prefix": "admin!~admin@host", "nick": "admin",
|
||||||
|
"added_by": "admin",
|
||||||
|
}
|
||||||
|
_save(bot, "#test:abc123", data)
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
_restore(bot)
|
||||||
|
assert "#test:abc123" in _tasks
|
||||||
|
assert not _tasks["#test:abc123"].done()
|
||||||
|
_clear()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
def test_restore_skips_active(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
data = {
|
||||||
|
"id": "active", "channel": "#test",
|
||||||
|
"command": "!ping", "interval": 300,
|
||||||
|
"prefix": "admin!~admin@host", "nick": "admin",
|
||||||
|
"added_by": "admin",
|
||||||
|
}
|
||||||
|
_save(bot, "#test:active", data)
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
dummy = asyncio.create_task(asyncio.sleep(9999))
|
||||||
|
_tasks["#test:active"] = dummy
|
||||||
|
_restore(bot)
|
||||||
|
assert _tasks["#test:active"] is dummy
|
||||||
|
dummy.cancel()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
def test_restore_replaces_done_task(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
data = {
|
||||||
|
"id": "done", "channel": "#test",
|
||||||
|
"command": "!ping", "interval": 300,
|
||||||
|
"prefix": "admin!~admin@host", "nick": "admin",
|
||||||
|
"added_by": "admin",
|
||||||
|
}
|
||||||
|
_save(bot, "#test:done", data)
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
done_task = asyncio.create_task(asyncio.sleep(0))
|
||||||
|
await done_task
|
||||||
|
_tasks["#test:done"] = done_task
|
||||||
|
_restore(bot)
|
||||||
|
new_task = _tasks["#test:done"]
|
||||||
|
assert new_task is not done_task
|
||||||
|
assert not new_task.done()
|
||||||
|
_clear()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
def test_restore_skips_bad_json(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
bot.state.set("cron", "#test:bad", "not json{{{")
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
_restore(bot)
|
||||||
|
assert "#test:bad" not in _tasks
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
def test_on_connect_calls_restore(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
data = {
|
||||||
|
"id": "conn", "channel": "#test",
|
||||||
|
"command": "!ping", "interval": 300,
|
||||||
|
"prefix": "admin!~admin@host", "nick": "admin",
|
||||||
|
"added_by": "admin",
|
||||||
|
}
|
||||||
|
_save(bot, "#test:conn", data)
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
msg = _msg("", target="botname")
|
||||||
|
await on_connect(bot, msg)
|
||||||
|
assert "#test:conn" in _tasks
|
||||||
|
_clear()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCronLoop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCronLoop:
|
||||||
|
def test_dispatches_command(self):
|
||||||
|
"""Cron loop dispatches a synthetic message after interval."""
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
data = {
|
||||||
|
"id": "loop1", "channel": "#test",
|
||||||
|
"command": "!ping", "interval": 0.05,
|
||||||
|
"prefix": "admin!~admin@host", "nick": "admin",
|
||||||
|
"added_by": "admin",
|
||||||
|
}
|
||||||
|
key = "#test:loop1"
|
||||||
|
_jobs[key] = data
|
||||||
|
_start_job(bot, key)
|
||||||
|
await asyncio.sleep(0.15)
|
||||||
|
_stop_job(key)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
# Should have dispatched at least once
|
||||||
|
assert len(bot.dispatched) >= 1
|
||||||
|
msg = bot.dispatched[0]
|
||||||
|
assert msg.nick == "admin"
|
||||||
|
assert msg.params[0] == "#test"
|
||||||
|
assert msg.params[1] == "!ping"
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
def test_loop_stops_on_job_removal(self):
|
||||||
|
"""Cron loop exits when job is removed from _jobs."""
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
data = {
|
||||||
|
"id": "loop2", "channel": "#test",
|
||||||
|
"command": "!ping", "interval": 0.05,
|
||||||
|
"prefix": "admin!~admin@host", "nick": "admin",
|
||||||
|
"added_by": "admin",
|
||||||
|
}
|
||||||
|
key = "#test:loop2"
|
||||||
|
_jobs[key] = data
|
||||||
|
_start_job(bot, key)
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
_jobs.pop(key, None)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
task = _tasks.get(key)
|
||||||
|
if task:
|
||||||
|
assert task.done()
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestJobManagement
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestJobManagement:
|
||||||
|
def test_start_and_stop(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
data = {
|
||||||
|
"id": "mgmt", "channel": "#test",
|
||||||
|
"command": "!ping", "interval": 300,
|
||||||
|
"prefix": "admin!~admin@host", "nick": "admin",
|
||||||
|
"added_by": "admin",
|
||||||
|
}
|
||||||
|
key = "#test:mgmt"
|
||||||
|
_jobs[key] = data
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
_start_job(bot, key)
|
||||||
|
assert key in _tasks
|
||||||
|
assert not _tasks[key].done()
|
||||||
|
_stop_job(key)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert key not in _tasks
|
||||||
|
assert key not in _jobs
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
def test_start_idempotent(self):
|
||||||
|
_clear()
|
||||||
|
bot = _FakeBot()
|
||||||
|
data = {
|
||||||
|
"id": "idem", "channel": "#test",
|
||||||
|
"command": "!ping", "interval": 300,
|
||||||
|
"prefix": "admin!~admin@host", "nick": "admin",
|
||||||
|
"added_by": "admin",
|
||||||
|
}
|
||||||
|
key = "#test:idem"
|
||||||
|
_jobs[key] = data
|
||||||
|
|
||||||
|
async def inner():
|
||||||
|
_start_job(bot, key)
|
||||||
|
first = _tasks[key]
|
||||||
|
_start_job(bot, key)
|
||||||
|
assert _tasks[key] is first
|
||||||
|
_stop_job(key)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
asyncio.run(inner())
|
||||||
|
|
||||||
|
def test_stop_nonexistent(self):
|
||||||
|
_clear()
|
||||||
|
_stop_job("#test:nonexistent")
|
||||||
Reference in New Issue
Block a user