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:
@@ -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})")
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user