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})")

View File

@@ -2,8 +2,11 @@
import asyncio
import importlib.util
import json
import sys
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
from derp.irc import Message
@@ -17,13 +20,22 @@ _spec.loader.exec_module(_mod)
from plugins.remind import ( # noqa: E402
_by_user,
_calendar,
_cleanup,
_delete_saved,
_format_duration,
_get_tz,
_make_id,
_next_yearly,
_parse_date,
_parse_duration,
_parse_time,
_remind_once,
_remind_repeat,
_reminders,
_restore,
_save,
_schedule_at,
cmd_remind,
)
@@ -31,12 +43,37 @@ from plugins.remind import ( # noqa: E402
# Helpers
# ---------------------------------------------------------------------------
class _FakeState:
"""In-memory stand-in for bot.state (SQLite KV)."""
def __init__(self):
self._store: dict[str, dict[str, str]] = {}
def get(self, plugin: str, key: str, default: str | None = None) -> str | None:
return self._store.get(plugin, {}).get(key, default)
def set(self, plugin: str, key: str, value: str) -> None:
self._store.setdefault(plugin, {})[key] = value
def delete(self, plugin: str, key: str) -> bool:
try:
del self._store[plugin][key]
return True
except KeyError:
return False
def keys(self, plugin: str) -> list[str]:
return sorted(self._store.get(plugin, {}).keys())
class _FakeBot:
"""Minimal bot stand-in that captures sent/replied messages."""
def __init__(self):
def __init__(self, *, tz: str = "UTC"):
self.sent: list[tuple[str, str]] = []
self.replied: list[str] = []
self.config: dict = {"bot": {"timezone": tz}}
self.state = _FakeState()
async def send(self, target: str, text: str) -> None:
self.sent.append((target, text))
@@ -69,6 +106,7 @@ def _clear() -> None:
task.cancel()
_reminders.clear()
_by_user.clear()
_calendar.clear()
async def _run_cmd(bot, msg):
@@ -223,6 +261,16 @@ class TestCleanup:
assert "abc" not in _reminders
def test_clears_calendar_set(self):
_clear()
_reminders["cal01"] = (None, "#ch", "alice", "", "12:00", False)
_by_user[("#ch", "alice")] = ["cal01"]
_calendar.add("cal01")
_cleanup("cal01", "#ch", "alice")
assert "cal01" not in _calendar
# ---------------------------------------------------------------------------
# _remind_once
@@ -601,3 +649,511 @@ class TestCmdRemindTarget:
await asyncio.sleep(0)
asyncio.run(inner())
# ---------------------------------------------------------------------------
# Calendar helpers: _parse_date
# ---------------------------------------------------------------------------
class TestParseDate:
def test_valid_iso_date(self):
assert _parse_date("2027-02-15") == date(2027, 2, 15)
def test_invalid_format_no_dashes(self):
assert _parse_date("20270215") is None
def test_invalid_month(self):
assert _parse_date("2027-13-01") is None
def test_invalid_day(self):
assert _parse_date("2027-02-30") is None
def test_leap_day_valid(self):
assert _parse_date("2028-02-29") == date(2028, 2, 29)
def test_leap_day_invalid_year(self):
assert _parse_date("2027-02-29") is None
def test_empty_string(self):
assert _parse_date("") is None
def test_garbage(self):
assert _parse_date("not-a-date") is None
# ---------------------------------------------------------------------------
# Calendar helpers: _parse_time
# ---------------------------------------------------------------------------
class TestParseTime:
def test_valid_noon(self):
assert _parse_time("12:00") == (12, 0)
def test_valid_midnight(self):
assert _parse_time("00:00") == (0, 0)
def test_valid_end_of_day(self):
assert _parse_time("23:59") == (23, 59)
def test_invalid_hour(self):
assert _parse_time("24:00") is None
def test_invalid_minute(self):
assert _parse_time("12:60") is None
def test_no_colon(self):
assert _parse_time("1200") is None
def test_single_digit(self):
assert _parse_time("9:00") is None
def test_empty(self):
assert _parse_time("") is None
# ---------------------------------------------------------------------------
# Calendar helpers: _next_yearly
# ---------------------------------------------------------------------------
class TestNextYearly:
def test_future_this_year(self):
tz = ZoneInfo("UTC")
now = datetime.now(tz)
# Pick a month in the future
future_month = now.month + 1 if now.month < 12 else 1
future_year = now.year if now.month < 12 else now.year + 1
result = _next_yearly(future_month, 15, 12, 0, tz)
assert result.year == future_year
assert result.month == future_month
assert result.day == 15
def test_past_this_year_rolls_to_next(self):
tz = ZoneInfo("UTC")
now = datetime.now(tz)
# Pick January 1 -- almost always in the past
result = _next_yearly(1, 1, 0, 0, tz)
assert result.year == now.year + 1
assert result.month == 1
assert result.day == 1
def test_leap_day_clamped_in_non_leap_year(self):
tz = ZoneInfo("UTC")
# 2027 is not a leap year
result = _next_yearly(2, 29, 12, 0, tz)
# Should clamp to 28 if the computed year is non-leap
if result.year % 4 != 0:
assert result.day == 28
else:
assert result.day == 29
def test_timezone_aware(self):
tz = ZoneInfo("Europe/Brussels")
result = _next_yearly(6, 15, 14, 30, tz)
assert result.tzinfo == tz
assert result.hour == 14
assert result.minute == 30
# ---------------------------------------------------------------------------
# Calendar helpers: _get_tz
# ---------------------------------------------------------------------------
class TestGetTz:
def test_default_utc(self):
bot = _FakeBot()
bot.config = {"bot": {}}
assert _get_tz(bot) == ZoneInfo("UTC")
def test_configured_timezone(self):
bot = _FakeBot(tz="Europe/Brussels")
assert _get_tz(bot) == ZoneInfo("Europe/Brussels")
def test_invalid_timezone_falls_back(self):
bot = _FakeBot()
bot.config = {"bot": {"timezone": "Not/A/Timezone"}}
assert _get_tz(bot) == ZoneInfo("UTC")
def test_missing_bot_section(self):
bot = _FakeBot()
bot.config = {}
assert _get_tz(bot) == ZoneInfo("UTC")
# ---------------------------------------------------------------------------
# cmd_remind at: calendar one-shot
# ---------------------------------------------------------------------------
class TestCmdRemindAt:
def _extract_rid(self, reply: str) -> str:
return reply.split("#")[1].split(" ")[0]
def test_valid_future_date(self):
_clear()
bot = _FakeBot()
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
async def inner():
await _run_cmd_and_cleanup(bot, _msg(f"!remind at {future} deploy release"))
asyncio.run(inner())
assert len(bot.replied) == 1
assert "set (at" in bot.replied[0]
assert "deploy release" not in bot.replied[0] # label not in confirmation
def test_past_date_rejected(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind at 2020-01-01 old stuff"))
asyncio.run(inner())
assert "past" in bot.replied[0].lower()
def test_default_time_noon(self):
_clear()
bot = _FakeBot()
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
async def inner():
await _run_cmd_and_cleanup(bot, _msg(f"!remind at {future} no time given"))
asyncio.run(inner())
assert "12:00" in bot.replied[0]
def test_with_explicit_time(self):
_clear()
bot = _FakeBot()
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
async def inner():
await _run_cmd_and_cleanup(bot, _msg(f"!remind at {future} 14:30 deploy"))
asyncio.run(inner())
assert "14:30" in bot.replied[0]
def test_stores_in_state(self):
_clear()
bot = _FakeBot()
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
async def inner():
await _run_cmd(bot, _msg(f"!remind at {future} persist me"))
rid = self._extract_rid(bot.replied[0])
raw = bot.state.get("remind", rid)
assert raw is not None
data = json.loads(raw)
assert data["type"] == "at"
assert data["nick"] == "alice"
assert data["label"] == "persist me"
assert rid in _calendar
for e in _reminders.values():
e[0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())
def test_invalid_date_format(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind at not-a-date some text"))
asyncio.run(inner())
assert "Invalid date" in bot.replied[0]
def test_no_args_shows_usage(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind at"))
asyncio.run(inner())
assert "Usage:" in bot.replied[0]
# ---------------------------------------------------------------------------
# cmd_remind yearly: recurring
# ---------------------------------------------------------------------------
class TestCmdRemindYearly:
def _extract_rid(self, reply: str) -> str:
return reply.split("#")[1].split(" ")[0]
def test_valid_creation(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind yearly 06-15 happy anniversary"))
asyncio.run(inner())
assert len(bot.replied) == 1
assert "yearly 06-15" in bot.replied[0]
def test_invalid_date(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind yearly 13-01 bad month"))
asyncio.run(inner())
assert "Invalid date" in bot.replied[0]
def test_invalid_day(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind yearly 04-31 too many days"))
asyncio.run(inner())
assert "Invalid date" in bot.replied[0]
def test_stores_in_state(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd(bot, _msg("!remind yearly 02-14 valentines"))
rid = self._extract_rid(bot.replied[0])
raw = bot.state.get("remind", rid)
assert raw is not None
data = json.loads(raw)
assert data["type"] == "yearly"
assert data["month_day"] == "02-14"
assert data["nick"] == "alice"
assert rid in _calendar
for e in _reminders.values():
e[0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())
def test_with_explicit_time(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind yearly 12-25 09:00 merry christmas"))
asyncio.run(inner())
assert "yearly 12-25" in bot.replied[0]
def test_no_args_shows_usage(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind yearly"))
asyncio.run(inner())
assert "Usage:" in bot.replied[0]
def test_leap_day_allowed(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind yearly 02-29 leap day birthday"))
asyncio.run(inner())
assert "yearly 02-29" in bot.replied[0]
# ---------------------------------------------------------------------------
# Calendar persistence
# ---------------------------------------------------------------------------
class TestCalendarPersistence:
def _extract_rid(self, reply: str) -> str:
return reply.split("#")[1].split(" ")[0]
def test_save_and_load_roundtrip(self):
bot = _FakeBot()
data = {
"type": "at",
"target": "#test",
"nick": "alice",
"label": "test label",
"fire_iso": "2099-06-15T12:00:00+00:00",
"month_day": None,
"time_str": "12:00",
"created": "12:00:00 UTC",
}
_save(bot, "abc123", data)
raw = bot.state.get("remind", "abc123")
assert raw is not None
loaded = json.loads(raw)
assert loaded == data
def test_delete_removes(self):
bot = _FakeBot()
_save(bot, "abc123", {"type": "at"})
_delete_saved(bot, "abc123")
assert bot.state.get("remind", "abc123") is None
def test_cancel_deletes_from_state(self):
_clear()
bot = _FakeBot()
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
async def inner():
await _run_cmd(bot, _msg(f"!remind at {future} cancel me"))
rid = self._extract_rid(bot.replied[0])
assert bot.state.get("remind", rid) is not None
bot.replied.clear()
await cmd_remind(bot, _msg(f"!remind cancel {rid}"))
await asyncio.sleep(0)
assert bot.state.get("remind", rid) is None
asyncio.run(inner())
def test_at_fire_deletes_from_state(self):
_clear()
bot = _FakeBot()
async def inner():
rid = "fire01"
fire_dt = datetime.now(timezone.utc) + timedelta(seconds=0)
data = {
"type": "at", "target": "#ch", "nick": "alice",
"label": "fire now", "fire_iso": fire_dt.isoformat(),
"month_day": None, "time_str": "12:00",
"created": "12:00:00 UTC",
}
_save(bot, rid, data)
_calendar.add(rid)
task = asyncio.create_task(
_schedule_at(bot, rid, "#ch", "alice", "fire now", fire_dt, "12:00:00 UTC"),
)
_reminders[rid] = (task, "#ch", "alice", "fire now", "12:00:00 UTC", False)
_by_user[("#ch", "alice")] = [rid]
await task
assert bot.state.get("remind", rid) is None
asyncio.run(inner())
# ---------------------------------------------------------------------------
# Restore
# ---------------------------------------------------------------------------
class TestRestore:
def test_restores_at_from_state(self):
_clear()
bot = _FakeBot()
fire_dt = datetime.now(timezone.utc) + timedelta(hours=1)
data = {
"type": "at", "target": "#ch", "nick": "alice",
"label": "future task", "fire_iso": fire_dt.isoformat(),
"month_day": None, "time_str": "12:00",
"created": "12:00:00 UTC",
}
_save(bot, "rest01", data)
async def inner():
_restore(bot)
assert "rest01" in _reminders
assert "rest01" in _calendar
entry = _reminders["rest01"]
assert not entry[0].done()
entry[0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())
def test_restores_yearly_from_state(self):
_clear()
bot = _FakeBot()
fire_dt = datetime.now(timezone.utc) + timedelta(days=180)
data = {
"type": "yearly", "target": "#ch", "nick": "bob",
"label": "anniversary", "fire_iso": fire_dt.isoformat(),
"month_day": "06-15", "time_str": "12:00",
"created": "12:00:00 UTC",
}
_save(bot, "rest02", data)
async def inner():
_restore(bot)
assert "rest02" in _reminders
assert "rest02" in _calendar
entry = _reminders["rest02"]
assert not entry[0].done()
entry[0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())
def test_skips_active_rids(self):
_clear()
bot = _FakeBot()
fire_dt = datetime.now(timezone.utc) + timedelta(hours=1)
data = {
"type": "at", "target": "#ch", "nick": "alice",
"label": "active", "fire_iso": fire_dt.isoformat(),
"month_day": None, "time_str": "12:00",
"created": "12:00:00 UTC",
}
_save(bot, "skip01", data)
async def inner():
# Pre-populate with an active task
dummy = asyncio.create_task(asyncio.sleep(9999))
_reminders["skip01"] = (dummy, "#ch", "alice", "active", "12:00:00 UTC", False)
_restore(bot)
# Should still be the dummy task, not replaced
assert _reminders["skip01"][0] is dummy
dummy.cancel()
await asyncio.sleep(0)
asyncio.run(inner())
def test_past_at_cleaned_up(self):
_clear()
bot = _FakeBot()
past_dt = datetime.now(timezone.utc) - timedelta(hours=1)
data = {
"type": "at", "target": "#ch", "nick": "alice",
"label": "expired", "fire_iso": past_dt.isoformat(),
"month_day": None, "time_str": "12:00",
"created": "12:00:00 UTC",
}
_save(bot, "past01", data)
async def inner():
_restore(bot)
# Past at-reminder should be deleted from state, not scheduled
assert "past01" not in _reminders
assert bot.state.get("remind", "past01") is None
asyncio.run(inner())
def test_past_yearly_recalculated(self):
_clear()
bot = _FakeBot()
past_dt = datetime.now(timezone.utc) - timedelta(days=30)
data = {
"type": "yearly", "target": "#ch", "nick": "alice",
"label": "birthday", "fire_iso": past_dt.isoformat(),
"month_day": "01-01", "time_str": "12:00",
"created": "12:00:00 UTC",
}
_save(bot, "yearly01", data)
async def inner():
_restore(bot)
assert "yearly01" in _reminders
# fire_iso should have been updated to a future date
raw = bot.state.get("remind", "yearly01")
updated = json.loads(raw)
new_fire = datetime.fromisoformat(updated["fire_iso"])
assert new_fire > datetime.now(timezone.utc)
_reminders["yearly01"][0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())