Files
derp/plugins/remind.py
user 70d203f96e feat: add remind plugin with one-shot and repeating reminders
Supports duration parsing (5m, 1h30m, 2d12h), short hex IDs for
tracking, list/cancel subcommands, and repeating intervals via
`!remind every <duration> <text>`. Includes 23 unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:09:34 +01:00

187 lines
5.9 KiB
Python

"""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] <duration> <text> | list | cancel <id>")
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 <id>
"""
parts = message.text.split(None, 2)
if len(parts) < 2:
await bot.reply(message, "Usage: !remind [every] <duration> <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 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 <id>")
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]
duration = _parse_duration(parts[1] if not repeating else parts[2])
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})")