Files
derp/plugins/remind.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

513 lines
18 KiB
Python

"""Plugin: one-shot, repeating, and calendar-based reminders."""
from __future__ import annotations
import asyncio
import calendar
import hashlib
import json
import re
import time
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
from derp.plugin import command, event
_DURATION_RE = re.compile(r"(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$")
_DATE_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
_TIME_RE = re.compile(r"^(\d{2}):(\d{2})$")
def _make_id(nick: str, label: str) -> str:
"""Generate a short hex ID from nick + label + timestamp."""
raw = f"{nick}:{label}:{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)
# ---- Calendar helpers -------------------------------------------------------
def _get_tz(bot) -> ZoneInfo:
"""Get configured timezone, default UTC."""
name = bot.config.get("bot", {}).get("timezone", "UTC")
try:
return ZoneInfo(name)
except (KeyError, Exception):
return ZoneInfo("UTC")
def _parse_date(spec: str) -> date | None:
"""Parse YYYY-MM-DD into a date, or None."""
m = _DATE_RE.match(spec)
if not m:
return None
try:
return date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
except ValueError:
return None
def _parse_time(spec: str) -> tuple[int, int] | None:
"""Parse HH:MM into (hour, minute), or None."""
m = _TIME_RE.match(spec)
if not m:
return None
h, mi = int(m.group(1)), int(m.group(2))
if h > 23 or mi > 59:
return None
return (h, mi)
def _next_yearly(month: int, day: int, hour: int, minute: int,
tz: ZoneInfo) -> datetime:
"""Calculate the next occurrence of a yearly date as aware datetime."""
now = datetime.now(tz)
year = now.year
clamped_day = min(day, calendar.monthrange(year, month)[1])
candidate = datetime(year, month, clamped_day, hour, minute, tzinfo=tz)
if candidate <= now:
year += 1
clamped_day = min(day, calendar.monthrange(year, month)[1])
candidate = datetime(year, month, clamped_day, hour, minute, tzinfo=tz)
return candidate
# ---- Persistence helpers ----------------------------------------------------
def _save(bot, rid: str, data: dict) -> None:
"""Persist a calendar reminder to bot.state."""
bot.state.set("remind", rid, json.dumps(data))
def _delete_saved(bot, rid: str) -> None:
"""Remove a calendar reminder from bot.state."""
bot.state.delete("remind", rid)
# ---- Per-bot runtime state --------------------------------------------------
def _ps(bot):
"""Per-bot plugin runtime state."""
return bot._pstate.setdefault("remind", {
"reminders": {},
"by_user": {},
"calendar": set(),
})
def _cleanup(bot, rid: str, target: str, nick: str) -> None:
"""Remove a reminder from tracking structures."""
ps = _ps(bot)
ps["reminders"].pop(rid, None)
ps["calendar"].discard(rid)
ukey = (target, nick)
if ukey in ps["by_user"]:
ps["by_user"][ukey] = [r for r in ps["by_user"][ukey] if r != rid]
if not ps["by_user"][ukey]:
del ps["by_user"][ukey]
def _track(bot, rid: str, task: asyncio.Task, target: str, nick: str,
label: str, created: str, repeating: bool) -> None:
"""Add a reminder to in-memory tracking."""
ps = _ps(bot)
ps["reminders"][rid] = (task, target, nick, label, created, repeating)
ps["by_user"].setdefault((target, nick), []).append(rid)
# ---- Coroutines -------------------------------------------------------------
async def _remind_once(bot, rid: str, target: str, nick: str, label: str,
duration: int, created: str) -> None:
"""One-shot reminder: sleep, fire, clean up."""
try:
await asyncio.sleep(duration)
await bot.send(target, f"{nick}: reminder #{rid} (set {created})")
if label:
await bot.send(target, label)
except asyncio.CancelledError:
pass
finally:
_cleanup(bot, rid, target, nick)
async def _remind_repeat(bot, rid: str, target: str, nick: str, label: str,
interval: int, created: str) -> None:
"""Repeating reminder: fire every interval until cancelled."""
try:
while True:
await asyncio.sleep(interval)
await bot.send(target, f"{nick}: reminder #{rid} (every {_format_duration(interval)})")
if label:
await bot.send(target, label)
except asyncio.CancelledError:
pass
finally:
_cleanup(bot, rid, target, nick)
async def _schedule_at(bot, rid: str, target: str, nick: str, label: str,
fire_dt: datetime, created: str) -> None:
"""One-shot calendar reminder: sleep until fire_dt, send, delete."""
try:
now = datetime.now(timezone.utc)
delta = max(0, (fire_dt - now).total_seconds())
await asyncio.sleep(delta)
await bot.send(target, f"{nick}: reminder #{rid} (set {created})")
if label:
await bot.send(target, label)
_delete_saved(bot, rid)
except asyncio.CancelledError:
pass
finally:
_cleanup(bot, rid, target, nick)
async def _schedule_yearly(bot, rid: str, target: str, nick: str,
label: str, fire_dt: datetime, month: int,
day: int, hour: int, minute: int,
tz: ZoneInfo, created: str) -> None:
"""Yearly recurring reminder: fire, compute next year, loop."""
try:
dt = fire_dt
while True:
now = datetime.now(timezone.utc)
delta = max(0, (dt - now).total_seconds())
await asyncio.sleep(delta)
await bot.send(target, f"{nick}: reminder #{rid} (yearly)")
if label:
await bot.send(target, label)
# Compute next year's date
dt = _next_yearly(month, day, hour, minute, tz)
# Update persisted fire_iso
stored = bot.state.get("remind", rid)
if stored:
data = json.loads(stored)
data["fire_iso"] = dt.isoformat()
_save(bot, rid, data)
except asyncio.CancelledError:
pass
finally:
_cleanup(bot, rid, target, nick)
# ---- Restore on connect -----------------------------------------------------
def _restore(bot) -> None:
"""Restore persisted calendar reminders from bot.state."""
ps = _ps(bot)
for rid in bot.state.keys("remind"):
# Skip if already active
entry = ps["reminders"].get(rid)
if entry and not entry[0].done():
continue
raw = bot.state.get("remind", rid)
if not raw:
continue
try:
data = json.loads(raw)
except json.JSONDecodeError:
continue
target = data["target"]
nick = data["nick"]
label = data["label"]
created = data["created"]
fire_dt = datetime.fromisoformat(data["fire_iso"])
rtype = data["type"]
if rtype == "at":
if fire_dt <= datetime.now(timezone.utc):
_delete_saved(bot, rid)
continue
task = asyncio.create_task(
_schedule_at(bot, rid, target, nick, label, fire_dt, created),
)
elif rtype == "yearly":
tz = _get_tz(bot)
month_day = data["month_day"]
month = int(month_day.split("-")[0])
day_raw = int(month_day.split("-")[1])
hm = _parse_time(data["time_str"]) or (12, 0)
hour, minute = hm
# Recalculate if past
if fire_dt <= datetime.now(timezone.utc):
fire_dt = _next_yearly(month, day_raw, hour, minute, tz)
data["fire_iso"] = fire_dt.isoformat()
_save(bot, rid, data)
task = asyncio.create_task(
_schedule_yearly(bot, rid, target, nick, label, fire_dt,
month, day_raw, hour, minute, tz, created),
)
else:
continue
ps["calendar"].add(rid)
_track(bot, rid, task, target, nick, label, created, rtype == "yearly")
@event("001")
async def on_connect(bot, message):
"""Restore persisted calendar reminders on connect."""
_restore(bot)
# ---- Command handler ---------------------------------------------------------
@command("remind", help="Reminder: !remind [every|at|yearly] <spec> <text> | list | cancel <id>")
async def cmd_remind(bot, message):
"""Set a one-shot, repeating, or calendar-based reminder.
Usage:
!remind 5m check the oven
!remind every 1h drink water
!remind at 2027-02-15 14:00 deploy release
!remind yearly 02-15 happy anniversary
!remind list
!remind cancel <id>
"""
parts = message.text.split(None, 2)
if len(parts) < 2:
await bot.reply(
message,
"Usage: !remind [every|at|yearly] <spec> <text> | list | cancel <id>",
)
return
target = message.target if message.is_channel else message.nick
nick = message.nick
sub = parts[1].lower()
ukey = (target, nick)
# ---- List ----------------------------------------------------------------
if sub == "list":
ps = _ps(bot)
rids = ps["by_user"].get(ukey, [])
active = []
for rid in rids:
entry = ps["reminders"].get(rid)
if entry and not entry[0].done():
if rid in ps["calendar"]:
# Show next fire time
raw = bot.state.get("remind", rid)
if raw:
data = json.loads(raw)
fire_iso = data["fire_iso"]
fire_dt = datetime.fromisoformat(fire_iso)
tz = _get_tz(bot)
local = fire_dt.astimezone(tz)
tag_type = data["type"]
time_str = local.strftime("%Y-%m-%d %H:%M")
tag = f"#{rid} ({tag_type} {time_str})"
else:
tag = f"#{rid} (calendar)"
elif entry[5]:
tag = f"#{rid} (repeat)"
else:
tag = f"#{rid}"
active.append(tag)
if not active:
await bot.reply(message, "No active reminders")
return
await bot.reply(message, f"Reminders: {', '.join(active)}")
return
# ---- Cancel --------------------------------------------------------------
if sub == "cancel":
rid = parts[2].lstrip("#") if len(parts) > 2 else ""
if not rid:
await bot.reply(message, "Usage: !remind cancel <id>")
return
entry = _ps(bot)["reminders"].get(rid)
if entry and not entry[0].done() and entry[2] == nick:
entry[0].cancel()
_delete_saved(bot, rid)
await bot.reply(message, f"Cancelled #{rid}")
else:
await bot.reply(message, f"No active reminder #{rid}")
return
# ---- At (calendar one-shot) ----------------------------------------------
if sub == "at":
rest = parts[2] if len(parts) > 2 else ""
tokens = rest.split(None, 2) if rest else []
if not tokens:
await bot.reply(message, "Usage: !remind at <YYYY-MM-DD> [HH:MM] <text>")
return
d = _parse_date(tokens[0])
if d is None:
await bot.reply(message, "Invalid date (use: YYYY-MM-DD)")
return
# Try to parse optional time
hour, minute = 12, 0
time_str = "12:00"
label_start = 1
if len(tokens) > 1:
parsed = _parse_time(tokens[1])
if parsed:
hour, minute = parsed
time_str = tokens[1]
label_start = 2
label = " ".join(tokens[label_start:]) if len(tokens) > label_start else ""
tz = _get_tz(bot)
fire_dt = datetime(d.year, d.month, d.day, hour, minute, tzinfo=tz)
fire_utc = fire_dt.astimezone(timezone.utc)
if fire_utc <= datetime.now(timezone.utc):
await bot.reply(message, "That date/time is in the past")
return
rid = _make_id(nick, label)
created = datetime.now(timezone.utc).strftime("%H:%M:%S UTC")
data = {
"type": "at",
"target": target,
"nick": nick,
"label": label,
"fire_iso": fire_utc.isoformat(),
"month_day": None,
"time_str": time_str,
"created": created,
}
_save(bot, rid, data)
_ps(bot)["calendar"].add(rid)
task = asyncio.create_task(
_schedule_at(bot, rid, target, nick, label, fire_utc, created),
)
_track(bot, rid, task, target, nick, label, created, False)
local_str = fire_dt.strftime("%Y-%m-%d %H:%M")
await bot.reply(message, f"Reminder #{rid} set (at {local_str})")
return
# ---- Yearly (recurring) --------------------------------------------------
if sub == "yearly":
rest = parts[2] if len(parts) > 2 else ""
tokens = rest.split(None, 2) if rest else []
if not tokens:
await bot.reply(message, "Usage: !remind yearly <MM-DD> [HH:MM] <text>")
return
# Parse MM-DD
md_parts = tokens[0].split("-")
if len(md_parts) != 2:
await bot.reply(message, "Invalid date (use: MM-DD)")
return
try:
month = int(md_parts[0])
day_raw = int(md_parts[1])
except ValueError:
await bot.reply(message, "Invalid date (use: MM-DD)")
return
if month < 1 or month > 12 or day_raw < 1 or day_raw > 31:
await bot.reply(message, "Invalid date (use: MM-DD)")
return
# Validate day for the given month (allow 29 for Feb -- leap day)
max_day = 29 if month == 2 else calendar.monthrange(2000, month)[1]
if day_raw > max_day:
await bot.reply(message, "Invalid date (use: MM-DD)")
return
hour, minute = 12, 0
time_str = "12:00"
label_start = 1
if len(tokens) > 1:
parsed = _parse_time(tokens[1])
if parsed:
hour, minute = parsed
time_str = tokens[1]
label_start = 2
label = " ".join(tokens[label_start:]) if len(tokens) > label_start else ""
tz = _get_tz(bot)
fire_dt = _next_yearly(month, day_raw, hour, minute, tz)
fire_utc = fire_dt.astimezone(timezone.utc)
rid = _make_id(nick, label)
created = datetime.now(timezone.utc).strftime("%H:%M:%S UTC")
month_day = f"{month:02d}-{day_raw:02d}"
data = {
"type": "yearly",
"target": target,
"nick": nick,
"label": label,
"fire_iso": fire_utc.isoformat(),
"month_day": month_day,
"time_str": time_str,
"created": created,
}
_save(bot, rid, data)
_ps(bot)["calendar"].add(rid)
task = asyncio.create_task(
_schedule_yearly(bot, rid, target, nick, label, fire_utc,
month, day_raw, hour, minute, tz, created),
)
_track(bot, rid, task, target, nick, label, created, True)
local_str = fire_dt.strftime("%Y-%m-%d %H:%M")
await bot.reply(message, f"Reminder #{rid} set (yearly {month_day}, next {local_str})")
return
# ---- Repeating (every) ---------------------------------------------------
repeating = False
if sub == "every":
repeating = True
rest = parts[2] if len(parts) > 2 else ""
parts = ["", "", *rest.split(None, 1)] # re-split: [_, _, duration, text]
dur_idx = 2 if repeating else 1
dur_spec = parts[dur_idx] if dur_idx < len(parts) else ""
duration = _parse_duration(dur_spec)
if duration is None:
await bot.reply(message, "Invalid duration (use: 5m, 1h30m, 2d, 90s)")
return
label = ""
if repeating:
label = parts[3] if len(parts) > 3 else ""
else:
label = parts[2] if len(parts) > 2 else ""
rid = _make_id(nick, label)
created = datetime.now(timezone.utc).strftime("%H:%M:%S UTC")
if repeating:
task = asyncio.create_task(
_remind_repeat(bot, rid, target, nick, label, duration, created),
)
else:
task = asyncio.create_task(
_remind_once(bot, rid, target, nick, label, duration, created),
)
_track(bot, rid, task, target, nick, label, created, repeating)
kind = f"every {_format_duration(duration)}" if repeating else _format_duration(duration)
await bot.reply(message, f"Reminder #{rid} set ({kind})")