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

@@ -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())