"""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] | list | cancel ") 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 """ parts = message.text.split(None, 2) if len(parts) < 2: await bot.reply( message, "Usage: !remind [every|at|yearly] | list | cancel ", ) 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 ") 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 [HH:MM] ") 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 [HH:MM] ") 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})")