"""Plugin: one-shot and repeating reminders with short ID tracking.""" from __future__ import annotations import asyncio import hashlib import re import time from datetime import datetime, timezone from derp.plugin import command _DURATION_RE = re.compile(r"(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$") 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) # 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]] = {} def _cleanup(rid: str, target: str, nick: str) -> None: """Remove a reminder from tracking structures.""" _reminders.pop(rid, None) ukey = (target, nick) if ukey in _by_user: _by_user[ukey] = [r for r in _by_user[ukey] if r != rid] if not _by_user[ukey]: del _by_user[ukey] 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(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(rid, target, nick) @command("remind", help="Reminder: !remind [every] | list | cancel ") async def cmd_remind(bot, message): """Set a one-shot or repeating reminder. Usage: !remind 5m check the oven !remind every 1h drink water !remind 2d12h renew cert !remind list !remind cancel """ parts = message.text.split(None, 2) if len(parts) < 2: await bot.reply(message, "Usage: !remind [every] | list | cancel ") return target = message.target if message.is_channel else message.nick nick = message.nick sub = parts[1].lower() ukey = (target, nick) # List active reminders 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}" active.append(tag) if not active: await bot.reply(message, "No active reminders") return await bot.reply(message, f"Reminders: {', '.join(active)}") return # Cancel by ID if sub == "cancel": rid = parts[2].lstrip("#") if len(parts) > 2 else "" if not rid: await bot.reply(message, "Usage: !remind cancel ") return entry = _reminders.get(rid) if entry and not entry[0].done() and entry[2] == nick: entry[0].cancel() await bot.reply(message, f"Cancelled #{rid}") else: await bot.reply(message, f"No active reminder #{rid}") return # Detect repeating flag 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), ) _reminders[rid] = (task, target, nick, label, created, repeating) _by_user.setdefault(ukey, []).append(rid) kind = f"every {_format_duration(duration)}" if repeating else _format_duration(duration) await bot.reply(message, f"Reminder #{rid} set ({kind})")