diff --git a/plugins/remind.py b/plugins/remind.py index 87bbf7f..8906bec 100644 --- a/plugins/remind.py +++ b/plugins/remind.py @@ -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] | list | cancel ") +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] | list | cancel ") 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 """ parts = message.text.split(None, 2) if len(parts) < 2: - await bot.reply(message, "Usage: !remind [every] | list | cancel ") + await bot.reply( + message, + "Usage: !remind [every|at|yearly] | list | cancel ", + ) 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 [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) + _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 [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) + _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})") diff --git a/tests/test_remind.py b/tests/test_remind.py index 82f5df7..f4bb165 100644 --- a/tests/test_remind.py +++ b/tests/test_remind.py @@ -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())