feat: add calendar-based reminders (at/yearly) with persistence

Calendar reminders use bot.state (SQLite KV) for persistence across
restarts. Supports one-shot at specific date/time and yearly recurring
reminders with leap day handling. Restored automatically on connect
via 001 event handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-15 12:39:42 +01:00
parent 021a0ddbe3
commit f888faf2bd
2 changed files with 890 additions and 15 deletions

View File

@@ -1,16 +1,21 @@
"""Plugin: one-shot and repeating reminders with short ID tracking."""
"""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 datetime, timezone
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
from derp.plugin import command
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:
@@ -54,15 +59,79 @@ def _format_duration(secs: int) -> str:
return "".join(parts)
# In-memory tracking: {rid: (task, target, nick, label, created, repeating)}
# ---- 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)
# ---- In-memory tracking -----------------------------------------------------
# {rid: (task, target, nick, label, created, repeating)}
_reminders: dict[str, tuple[asyncio.Task, str, str, str, str, bool]] = {}
# Reverse lookup: (target, nick) -> [rid, ...]
_by_user: dict[tuple[str, str], list[str]] = {}
# Calendar-based rids (persisted)
_calendar: set[str] = set()
def _cleanup(rid: str, target: str, nick: str) -> None:
"""Remove a reminder from tracking structures."""
_reminders.pop(rid, None)
_calendar.discard(rid)
ukey = (target, nick)
if ukey in _by_user:
_by_user[ukey] = [r for r in _by_user[ukey] if r != rid]
@@ -70,6 +139,15 @@ def _cleanup(rid: str, target: str, nick: str) -> None:
del _by_user[ukey]
def _track(rid: str, task: asyncio.Task, target: str, nick: str,
label: str, created: str, repeating: bool) -> None:
"""Add a reminder to in-memory tracking."""
_reminders[rid] = (task, target, nick, label, created, repeating)
_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."""
@@ -99,20 +177,131 @@ async def _remind_repeat(bot, rid: str, target: str, nick: str, label: str,
_cleanup(rid, target, nick)
@command("remind", help="Reminder: !remind [every] <duration> <text> | list | cancel <id>")
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(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(rid, target, nick)
# ---- Restore on connect -----------------------------------------------------
def _restore(bot) -> None:
"""Restore persisted calendar reminders from bot.state."""
for rid in bot.state.keys("remind"):
# Skip if already active
entry = _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
_calendar.add(rid)
_track(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 or repeating reminder.
"""Set a one-shot, repeating, or calendar-based reminder.
Usage:
!remind 5m check the oven
!remind every 1h drink water
!remind 2d12h renew cert
!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] <duration> <text> | list | cancel <id>")
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
@@ -120,14 +309,31 @@ async def cmd_remind(bot, message):
sub = parts[1].lower()
ukey = (target, nick)
# List active reminders
# ---- List ----------------------------------------------------------------
if sub == "list":
rids = _by_user.get(ukey, [])
active = []
for rid in rids:
entry = _reminders.get(rid)
if entry and not entry[0].done():
tag = f"#{rid} (repeat)" if entry[5] else f"#{rid}"
if rid in _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")
@@ -135,7 +341,7 @@ async def cmd_remind(bot, message):
await bot.reply(message, f"Reminders: {', '.join(active)}")
return
# Cancel by ID
# ---- Cancel --------------------------------------------------------------
if sub == "cancel":
rid = parts[2].lstrip("#") if len(parts) > 2 else ""
if not rid:
@@ -144,12 +350,126 @@ async def cmd_remind(bot, message):
entry = _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
# Detect repeating flag
# ---- 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)
_calendar.add(rid)
task = asyncio.create_task(
_schedule_at(bot, rid, target, nick, label, fire_utc, created),
)
_track(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)
_calendar.add(rid)
task = asyncio.create_task(
_schedule_yearly(bot, rid, target, nick, label, fire_utc,
month, day_raw, hour, minute, tz, created),
)
_track(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
@@ -181,8 +501,7 @@ async def cmd_remind(bot, message):
_remind_once(bot, rid, target, nick, label, duration, created),
)
_reminders[rid] = (task, target, nick, label, created, repeating)
_by_user.setdefault(ukey, []).append(rid)
_track(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})")