Dalsi vlna cisteni /remind od Claude

This commit is contained in:
lachtan
2026-06-10 08:48:26 +02:00
parent edf823fc72
commit abdfd8c67d
9 changed files with 237 additions and 184 deletions

View File

@@ -2,11 +2,11 @@
## Scheduled Reminders ## Scheduled Reminders
**Personal reminders for the user** (notifications about tasks they need to do) → use the `/remind` skill → stored in `reminder.yaml`. Never use the `cron` tool for these. **Personal reminders for the user** (notifications about tasks they need to do) → use the `/remind` skill → stored in SQLite (`db/reminders.sqlite`). Never use the `cron` tool for these.
**Background agent tasks** (run a script, check something, autonomous action) → use the built-in `cron` tool directly. **Background agent tasks** (run a script, check something, autonomous action) → use the built-in `cron` tool directly.
Test: *Who is the recipient?* User gets notified → `reminder.yaml` via `/remind` skill. Agent executes something → `cron` tool. Test: *Who is the recipient?* User gets notified → `/remind` skill (SQLite `db/reminders.sqlite`). Agent executes something → `cron` tool.
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications. **Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications.

View File

@@ -26,7 +26,7 @@ This file documents non-obvious constraints and usage patterns.
## cron — Background Agent Tasks ## cron — Background Agent Tasks
- Use `cron` only for **background agent tasks** (scripts, checks, autonomous actions). - Use `cron` only for **background agent tasks** (scripts, checks, autonomous actions).
- For **personal reminders to the user**, use the `/remind` skill instead (`reminder.yaml`). - For **personal reminders to the user**, use the `/remind` skill instead (stored in SQLite `db/reminders.sqlite`).
- Do not call `nanobot cron` via `exec` — use the built-in `cron` tool. - Do not call `nanobot cron` via `exec` — use the built-in `cron` tool.
## python — use uv ## python — use uv
@@ -48,12 +48,15 @@ Do not use `pip`, `pip-tools`, `poetry`, `conda`, or the system `python`.
Reason: isolated, reproducible environments with no system-level side Reason: isolated, reproducible environments with no system-level side
effects, faster resolves, no "works on my machine" surprises. effects, faster resolves, no "works on my machine" surprises.
## log/reminder.log — doručené připomínky ## Doručené připomínky — „co dnes přišlo?"
Odeslané připomínky se logují do `log/reminder.log` (append-only, formát Připomínky doručuje **systémový cron uživatele nanobot**
`YYYY-MM-DDTHH:MM:SS <text>`, Prague time, bez timezone suffixu). Posílá je (`skills/remind/scripts/remind_send.py`) přímo přes Telegram, mimo agenta —
**systémový cron uživatele nanobot** (`skills/remind/scripts/remind_send.py`) agent u odeslání není. Když se uživatel ptá na minulé/dnešní připomínky
přímo přes Telegram, mimo agenta. Když se uživatel ptá na minulé/dnešní („připomněl jsi mi…?", „co dnes přišlo?"), zavolej
připomínky („připomněl jsi mi…?", „co dnes přišlo?"), přečti tento soubor. `uv run skills/remind/scripts/remind_edit.py delivered [--since YYYY-MM-DD]`
Vedle něj v `log/reminder_cron.log` se zachytává stdout/stderr crontabu — čte tabulku `reminder_fires` (jen doručené, čas v Praze).
za normálního běhu prázdný, plní se jen při pádech skriptu.
`log/reminder.log` je provozní/debug log všech operací (ADD/EDIT/REMOVE/…/DELIVER,
UTC) — ne zdroj pravdy pro doručení. `log/reminder_cron.log` zachytává
stdout/stderr crontabu — za zdravého běhu prázdný, plní se jen při pádech skriptu.

View File

@@ -31,7 +31,16 @@ Parse natural language, then call `remind_edit.py add` with flags:
``` ```
/remind list /remind list
``` ```
Call `remind_edit.py list` → JSON with all active reminders. Call `remind_edit.py list`. Returns a readable text listing — each reminder is prefixed with
`#<id>` (that id is what `--id` selects), followed by indented schedule lines:
```
#3 drink water [enabled]
cron: 0 9 * * *
#7 take meds [disabled]
at: 2026-06-15T18:00:00
random: 2× daily 09:0021:00 (1-5) from 2026-06-01
```
An empty store prints `(no active reminders)`.
### Edit ### Edit
``` ```

View File

@@ -92,7 +92,7 @@ def init_db(path: Path) -> None:
conn.close() conn.close()
def log_operation(operation: str, reminder_id: int | None, details: str) -> None: def log_operation(operation: str, reminder_id: int | None, details: str | None) -> None:
"""Append an audit line to workspace/log/reminder.log.""" """Append an audit line to workspace/log/reminder.log."""
workspace = Path(__file__).resolve().parent.parent.parent.parent workspace = Path(__file__).resolve().parent.parent.parent.parent
log_dir = workspace / "log" log_dir = workspace / "log"

View File

@@ -27,7 +27,7 @@ def compute_fire_times(target_date: date, text: str, cfg: dict) -> list[datetime
a config regardless of the date passed in. a config regardless of the date passed in.
""" """
count = _parse_count(cfg.get("times_per_day")) count = _parse_count(cfg.get("times_per_day"))
start, end = _parse_window(cfg.get("window")) start, end = parse_window(cfg.get("window"))
day_set = _parse_days(cfg["days"]) if cfg.get("days") is not None else None day_set = _parse_days(cfg["days"]) if cfg.get("days") is not None else None
from_date = _parse_date(cfg["from"]) if cfg.get("from") is not None else None from_date = _parse_date(cfg["from"]) if cfg.get("from") is not None else None
until_date = _parse_date(cfg["until"]) if cfg.get("until") is not None else None until_date = _parse_date(cfg["until"]) if cfg.get("until") is not None else None
@@ -60,7 +60,7 @@ def _parse_count(raw: object) -> int:
return raw return raw
def _parse_window(raw: object) -> tuple[int, int]: def parse_window(raw: object) -> tuple[int, int]:
if not isinstance(raw, str) or "-" not in raw: if not isinstance(raw, str) or "-" not in raw:
raise ValueError(f"window must be 'HH:MM-HH:MM', got {raw!r}") raise ValueError(f"window must be 'HH:MM-HH:MM', got {raw!r}")
start_str, end_str = raw.split("-", 1) start_str, end_str = raw.split("-", 1)

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env -S uv run --script
# /// script # /// script
# requires-python = ">=3.11" # requires-python = ">=3.11"
# dependencies = ["croniter"] # dependencies = ["croniter"]
@@ -21,7 +21,7 @@ from zoneinfo import ZoneInfo
from croniter import croniter from croniter import croniter
from db import get_db, init_db, log_operation from db import get_db, init_db, log_operation
from random_times import compute_fire_times from random_times import compute_fire_times, parse_window
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite" DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite"
@@ -57,19 +57,7 @@ def _build_random(args: argparse.Namespace) -> dict | None:
return cfg return cfg
def _parse_window(window: str) -> tuple[int, int]: def _insert_schedules(conn, reminder_id: int, args: argparse.Namespace, random_cfg: dict | None) -> None:
start_str, end_str = window.split("-", 1)
start = _hhmm_to_minutes(start_str.strip())
end = _hhmm_to_minutes(end_str.strip())
return start, end
def _hhmm_to_minutes(value: str) -> int:
h, m = value.split(":")
return int(h) * 60 + int(m)
def _insert_schedules(conn, reminder_id: int, args: argparse.Namespace) -> None:
if args.at: if args.at:
for at_str in args.at: for at_str in args.at:
conn.execute( conn.execute(
@@ -82,9 +70,8 @@ def _insert_schedules(conn, reminder_id: int, args: argparse.Namespace) -> None:
"INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)", "INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)",
(reminder_id, expr), (reminder_id, expr),
) )
random_cfg = _build_random(args)
if random_cfg: if random_cfg:
start, end = _parse_window(random_cfg["window"]) start, end = parse_window(random_cfg["window"])
conn.execute( conn.execute(
""" """
INSERT INTO schedule_random INSERT INTO schedule_random
@@ -131,9 +118,11 @@ def _fetch_reminder(conn, reminder_id: int) -> dict:
def _find_by_keyword(conn, keyword: str) -> list[dict]: def _find_by_keyword(conn, keyword: str) -> list[dict]:
escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
rows = conn.execute( rows = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at FROM reminders WHERE text LIKE ? AND deleted_at IS NULL", "SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at "
(f"%{keyword}%",), "FROM reminders WHERE text LIKE ? ESCAPE '\\' AND deleted_at IS NULL",
(f"%{escaped}%",),
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
@@ -181,48 +170,52 @@ def cmd_list(_args: argparse.Namespace) -> int:
conn = get_db(DB_PATH) conn = get_db(DB_PATH)
try: try:
rows = conn.execute( rows = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at FROM reminders WHERE deleted_at IS NULL ORDER BY id" "SELECT id, text, enabled FROM reminders WHERE deleted_at IS NULL ORDER BY id"
).fetchall() ).fetchall()
if not rows: if not rows:
print("(no active reminders)")
return 0 return 0
for idx, row in enumerate(rows, start=1): for row in rows:
reminder = dict(row) rid = row["id"]
rid = reminder["id"] status = "enabled" if row["enabled"] else "disabled"
status = "enabled" if reminder["enabled"] else "disabled" print(f"#{rid} {row['text']} [{status}]")
print(f"{idx}. {reminder['text']} ({status})") for line in _schedule_lines(conn, rid):
print(f" {line}")
at_rows = conn.execute(
"SELECT at_datetime FROM schedule_at WHERE reminder_id = ?", (rid,)
).fetchall()
for r in at_rows:
print(f" - at: {r['at_datetime']}")
cron_rows = conn.execute(
"SELECT cron_expr FROM schedule_cron WHERE reminder_id = ?", (rid,)
).fetchall()
for r in cron_rows:
print(f" - cron: {r['cron_expr']}")
random_rows = conn.execute(
"SELECT times_per_day, window_start, window_end, days_filter, from_date, until_date FROM schedule_random WHERE reminder_id = ?",
(rid,),
).fetchall()
for r in random_rows:
parts = [f"random: {r['times_per_day']}× daily {r['window_start']}{r['window_end']}"]
if r["days_filter"]:
parts.append(f"({r['days_filter']})")
if r["from_date"]:
parts.append(f"from {r['from_date']}")
if r["until_date"]:
parts.append(f"until {r['until_date']}")
print(f" - {' '.join(parts)}")
return 0 return 0
finally: finally:
conn.close() conn.close()
def _schedule_lines(conn, reminder_id: int) -> list[str]:
"""Human-readable schedule descriptions for one reminder, in at/cron/random order."""
lines = []
for r in conn.execute("SELECT at_datetime FROM schedule_at WHERE reminder_id = ?", (reminder_id,)):
lines.append(f"at: {r['at_datetime']}")
for r in conn.execute("SELECT cron_expr FROM schedule_cron WHERE reminder_id = ?", (reminder_id,)):
lines.append(f"cron: {r['cron_expr']}")
random_rows = conn.execute(
"SELECT times_per_day, window_start, window_end, days_filter, from_date, until_date "
"FROM schedule_random WHERE reminder_id = ?",
(reminder_id,),
)
for r in random_rows:
window = f"{_minutes_to_hhmm(r['window_start'])}{_minutes_to_hhmm(r['window_end'])}"
parts = [f"random: {r['times_per_day']}× daily {window}"]
if r["days_filter"]:
parts.append(f"({r['days_filter']})")
if r["from_date"]:
parts.append(f"from {r['from_date']}")
if r["until_date"]:
parts.append(f"until {r['until_date']}")
lines.append(" ".join(parts))
return lines
def _minutes_to_hhmm(total: int) -> str:
return f"{total // 60:02d}:{total % 60:02d}"
def cmd_add(args: argparse.Namespace) -> int: def cmd_add(args: argparse.Namespace) -> int:
text = (args.text or "").strip() text = (args.text or "").strip()
if not text: if not text:
@@ -263,7 +256,7 @@ def cmd_add(args: argparse.Namespace) -> int:
(text, now, now), (text, now, now),
) )
reminder_id = cur.lastrowid reminder_id = cur.lastrowid
_insert_schedules(conn, reminder_id, args) _insert_schedules(conn, reminder_id, args, random_cfg)
conn.execute("COMMIT") conn.execute("COMMIT")
reminder = _fetch_reminder(conn, reminder_id) reminder = _fetch_reminder(conn, reminder_id)
log_operation("ADD", reminder_id, f'text="{text}"') log_operation("ADD", reminder_id, f'text="{text}"')
@@ -306,6 +299,19 @@ def cmd_edit(args: argparse.Namespace) -> int:
print(json.dumps({"error": "--replace-schedules requires at least one --cron/--at/--random-* option"}), file=sys.stderr) print(json.dumps({"error": "--replace-schedules requires at least one --cron/--at/--random-* option"}), file=sys.stderr)
return 1 return 1
new_text = None
if args.text:
new_text = args.text.strip()
if not new_text:
print(json.dumps({"error": "text must not be empty"}), file=sys.stderr)
return 1
try:
random_cfg = _build_random(args)
except ValueError as exc:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
_ensure_db() _ensure_db()
conn = get_db(DB_PATH) conn = get_db(DB_PATH)
try: try:
@@ -317,11 +323,7 @@ def cmd_edit(args: argparse.Namespace) -> int:
conn.execute("BEGIN") conn.execute("BEGIN")
now = _now() now = _now()
if args.text: if new_text is not None:
new_text = args.text.strip()
if not new_text:
print(json.dumps({"error": "text must not be empty"}), file=sys.stderr)
return 1
conn.execute("UPDATE reminders SET text = ?, updated_at = ? WHERE id = ?", (new_text, now, rid)) conn.execute("UPDATE reminders SET text = ?, updated_at = ? WHERE id = ?", (new_text, now, rid))
log_operation("EDIT", rid, f'text="{new_text}"') log_operation("EDIT", rid, f'text="{new_text}"')
@@ -329,7 +331,7 @@ def cmd_edit(args: argparse.Namespace) -> int:
conn.execute("DELETE FROM schedule_at WHERE reminder_id = ?", (rid,)) conn.execute("DELETE FROM schedule_at WHERE reminder_id = ?", (rid,))
conn.execute("DELETE FROM schedule_cron WHERE reminder_id = ?", (rid,)) conn.execute("DELETE FROM schedule_cron WHERE reminder_id = ?", (rid,))
conn.execute("DELETE FROM schedule_random WHERE reminder_id = ?", (rid,)) conn.execute("DELETE FROM schedule_random WHERE reminder_id = ?", (rid,))
_insert_schedules(conn, rid, args) _insert_schedules(conn, rid, args, random_cfg)
conn.execute("UPDATE reminders SET updated_at = ? WHERE id = ?", (now, rid)) conn.execute("UPDATE reminders SET updated_at = ? WHERE id = ?", (now, rid))
log_operation("EDIT", rid, "schedules replaced") log_operation("EDIT", rid, "schedules replaced")
@@ -403,21 +405,28 @@ def cmd_delivered(args: argparse.Namespace) -> int:
except ValueError as exc: except ValueError as exc:
print(json.dumps({"error": f"invalid --since date: {exc}"}), file=sys.stderr) print(json.dumps({"error": f"invalid --since date: {exc}"}), file=sys.stderr)
return 1 return 1
where, params = "f.fire_time >= ?", (since,) rows = conn.execute(
"""
SELECT f.delivered_at, r.text
FROM reminder_fires f
JOIN reminders r ON r.id = f.reminder_id
WHERE f.status = 'delivered' AND f.fire_time >= ?
ORDER BY f.delivered_at
""",
(since,),
).fetchall()
else: else:
today = datetime.now(PRAGUE).date().isoformat() today = datetime.now(PRAGUE).date().isoformat()
where, params = "substr(f.fire_time, 1, 10) = ?", (today,) rows = conn.execute(
"""
rows = conn.execute( SELECT f.delivered_at, r.text
f""" FROM reminder_fires f
SELECT f.delivered_at, r.text JOIN reminders r ON r.id = f.reminder_id
FROM reminder_fires f WHERE f.status = 'delivered' AND substr(f.fire_time, 1, 10) = ?
JOIN reminders r ON r.id = f.reminder_id ORDER BY f.delivered_at
WHERE f.status = 'delivered' AND {where} """,
ORDER BY f.delivered_at (today,),
""", ).fetchall()
params,
).fetchall()
for row in rows: for row in rows:
print(f"{row['delivered_at']} {row['text']}") print(f"{row['delivered_at']} {row['text']}")
return 0 return 0

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env -S uv run --script
# /// script # /// script
# requires-python = ">=3.11" # requires-python = ">=3.11"
# dependencies = ["croniter"] # dependencies = ["croniter"]
@@ -44,8 +44,7 @@ def _telegram_config() -> tuple[str, str]:
return telegram["token"], chat_id return telegram["token"], chat_id
def _send_telegram(text: str) -> None: def _send_telegram(text: str, token: str, chat_id: str) -> None:
token, chat_id = _telegram_config()
url = f"https://api.telegram.org/bot{token}/sendMessage" url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode() payload = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode()
req = urllib.request.Request(url, data=payload, method="POST") req = urllib.request.Request(url, data=payload, method="POST")
@@ -53,7 +52,7 @@ def _send_telegram(text: str) -> None:
resp.read() resp.read()
def _now() -> datetime: def _now_prague() -> datetime:
return datetime.now(TZ).replace(tzinfo=None) return datetime.now(TZ).replace(tzinfo=None)
@@ -170,7 +169,7 @@ def _minutes_to_hhmm(total: int) -> str:
def _record_fire(conn, reminder_id: int, schedule_id: int, schedule_type: str, fire_time: str, status: str, error: str | None = None) -> None: def _record_fire(conn, reminder_id: int, schedule_id: int, schedule_type: str, fire_time: str, status: str, error: str | None = None) -> None:
now = _now().isoformat(timespec="seconds") now = _now_prague().isoformat(timespec="seconds")
conn.execute( conn.execute(
""" """
INSERT INTO reminder_fires (reminder_id, schedule_id, schedule_type, fire_time, delivered_at, status, error_message) INSERT INTO reminder_fires (reminder_id, schedule_id, schedule_type, fire_time, delivered_at, status, error_message)
@@ -186,9 +185,12 @@ def main() -> None:
conn = get_db(DB_PATH) conn = get_db(DB_PATH)
try: try:
now = _now() now = _now_prague()
due = _due_at(conn, now) + _due_cron(conn, now) + _due_random(conn, now) due = _due_at(conn, now) + _due_cron(conn, now) + _due_random(conn, now)
if not due:
return
token, chat_id = _telegram_config()
for fire in due: for fire in due:
text = fire["text"] text = fire["text"]
rid = fire["id"] rid = fire["id"]
@@ -197,7 +199,7 @@ def main() -> None:
schedule_type = fire["schedule_type"] schedule_type = fire["schedule_type"]
try: try:
_send_telegram(f"⏰ Reminder: {text}") _send_telegram(f"⏰ Reminder: {text}", token, chat_id)
except Exception as e: except Exception as e:
print(f"remind_send: delivery failed for {text!r}: {e}", file=sys.stderr) print(f"remind_send: delivery failed for {text!r}: {e}", file=sys.stderr)
_record_fire(conn, rid, sid, schedule_type, ft, "failed", str(e)) _record_fire(conn, rid, sid, schedule_type, ft, "failed", str(e))

View File

@@ -1,11 +1,8 @@
import json import json
import os
import sqlite3
import sys import sys
from datetime import datetime
from pathlib import Path from pathlib import Path
import pytest
SCRIPTS = Path(__file__).parent.parent / "scripts" SCRIPTS = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS)) sys.path.insert(0, str(SCRIPTS))
@@ -39,7 +36,7 @@ def test_add_list(tmp_path, capsys):
ret = _run(db_path, ["list"]) ret = _run(db_path, ["list"])
captured = capsys.readouterr() captured = capsys.readouterr()
assert ret == 0 assert ret == 0
assert "drink water" in captured.out assert "#1 drink water [enabled]" in captured.out
assert "cron: 0 9 * * *" in captured.out assert "cron: 0 9 * * *" in captured.out
@@ -77,7 +74,7 @@ def test_remove(tmp_path, capsys):
ret = _run(db_path, ["list"]) ret = _run(db_path, ["list"])
captured = capsys.readouterr() captured = capsys.readouterr()
assert captured.out.strip() == "" assert "(no active reminders)" in captured.out
def test_remove_no_match(tmp_path, capsys): def test_remove_no_match(tmp_path, capsys):
@@ -257,3 +254,35 @@ def test_delivered_lists_deliveries(tmp_path, capsys):
assert "2026-06-01T09:00:01" in captured.out assert "2026-06-01T09:00:01" in captured.out
# failed fire is not reported as delivered # failed fire is not reported as delivered
assert captured.out.count("took pills") == 1 assert captured.out.count("took pills") == 1
def test_delivered_defaults_to_today(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
today = datetime.now(remind_edit.PRAGUE).date().isoformat()
conn = get_db(db_path)
try:
conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) "
"VALUES ('today pills', 1, 'Europe/Prague', 'now', 'now')"
)
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO reminder_fires (reminder_id, schedule_id, schedule_type, fire_time, delivered_at, status) "
"VALUES (?, 1, 'cron', ?, ?, 'delivered')",
(rid, f"{today}T09:00:00", f"{today}T09:00:01"),
)
# an older delivery must not show up when defaulting to today
conn.execute(
"INSERT INTO reminder_fires (reminder_id, schedule_id, schedule_type, fire_time, delivered_at, status) "
"VALUES (?, 1, 'cron', '2020-01-01T09:00:00', '2020-01-01T09:00:01', 'delivered')",
(rid,),
)
finally:
conn.close()
ret = _run(db_path, ["delivered"])
captured = capsys.readouterr()
assert ret == 0
assert captured.out.count("today pills") == 1
assert "2020-01-01" not in captured.out

View File

@@ -1,12 +1,7 @@
import json
import os
import sqlite3
import sys import sys
from datetime import datetime, timedelta from datetime import date, datetime
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import patch
import pytest
SCRIPTS = Path(__file__).parent.parent / "scripts" SCRIPTS = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS)) sys.path.insert(0, str(SCRIPTS))
@@ -16,40 +11,45 @@ import remind_send
def _run_send(db_path, now=None): def _run_send(db_path, now=None):
"""Run remind_send main with a temporary DB path and optional mocked now.""" """Run remind_send.main with a temporary DB, a stubbed config, and an optional fixed now."""
original_db_path = remind_send.DB_PATH original_db_path = remind_send.DB_PATH
original_now = remind_send._now_prague
original_config = remind_send._telegram_config
try: try:
remind_send.DB_PATH = db_path remind_send.DB_PATH = db_path
remind_send._telegram_config = lambda: ("token", "chat")
if now is not None: if now is not None:
remind_send._now = lambda: now remind_send._now_prague = lambda: now
remind_send.main() remind_send.main()
finally: finally:
remind_send.DB_PATH = original_db_path remind_send.DB_PATH = original_db_path
remind_send._now = lambda: datetime.now(remind_send.TZ).replace(tzinfo=None) remind_send._now_prague = original_now
remind_send._telegram_config = original_config
def test_due_at_delivers_once(tmp_path, capsys): def _add_reminder(conn, text, enabled=1, deleted_at=None):
conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at, deleted_at) "
"VALUES (?, ?, 'Europe/Prague', 'now', 'now', ?)",
(text, enabled, deleted_at),
)
return conn.execute("SELECT last_insert_rowid()").fetchone()[0]
def test_due_at_delivers_once(tmp_path):
db_path = tmp_path / "test.sqlite" db_path = tmp_path / "test.sqlite"
init_db(db_path) init_db(db_path)
conn = get_db(db_path) conn = get_db(db_path)
try: try:
conn.execute( rid = _add_reminder(conn, "at reminder")
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', 'now', 'now')", conn.execute("INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid, "2026-06-10T10:00:00"))
("at reminder",),
)
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
fire_time = "2026-06-10T10:00:00"
conn.execute(
"INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)",
(rid, fire_time),
)
finally: finally:
conn.close() conn.close()
now = datetime(2026, 6, 10, 10, 0, 0) now = datetime(2026, 6, 10, 10, 0, 0)
with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send: with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send:
_run_send(db_path, now) _run_send(db_path, now)
mock_send.assert_called_once_with("⏰ Reminder: at reminder") mock_send.assert_called_once_with("⏰ Reminder: at reminder", "token", "chat")
# second run — dedup # second run — dedup
with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send: with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send:
@@ -57,27 +57,20 @@ def test_due_at_delivers_once(tmp_path, capsys):
mock_send.assert_not_called() mock_send.assert_not_called()
def test_due_cron_delivers_once(tmp_path, capsys): def test_due_cron_delivers_once(tmp_path):
db_path = tmp_path / "test.sqlite" db_path = tmp_path / "test.sqlite"
init_db(db_path) init_db(db_path)
conn = get_db(db_path) conn = get_db(db_path)
try: try:
conn.execute( rid = _add_reminder(conn, "cron reminder")
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', 'now', 'now')", conn.execute("INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)", (rid, "0 10 * * *"))
("cron reminder",),
)
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)",
(rid, "0 10 * * *"),
)
finally: finally:
conn.close() conn.close()
now = datetime(2026, 6, 10, 10, 0, 0) now = datetime(2026, 6, 10, 10, 0, 0)
with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send: with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send:
_run_send(db_path, now) _run_send(db_path, now)
mock_send.assert_called_once_with("⏰ Reminder: cron reminder") mock_send.assert_called_once_with("⏰ Reminder: cron reminder", "token", "chat")
# second run — dedup # second run — dedup
with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send: with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send:
@@ -85,16 +78,12 @@ def test_due_cron_delivers_once(tmp_path, capsys):
mock_send.assert_not_called() mock_send.assert_not_called()
def test_due_random_delivers_once(tmp_path, capsys): def test_due_random_delivers_once(tmp_path):
db_path = tmp_path / "test.sqlite" db_path = tmp_path / "test.sqlite"
init_db(db_path) init_db(db_path)
conn = get_db(db_path) conn = get_db(db_path)
try: try:
conn.execute( rid = _add_reminder(conn, "random reminder")
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', 'now', 'now')",
("random reminder",),
)
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute( conn.execute(
"INSERT INTO schedule_random (reminder_id, times_per_day, window_start, window_end) VALUES (?, ?, ?, ?)", "INSERT INTO schedule_random (reminder_id, times_per_day, window_start, window_end) VALUES (?, ?, ?, ?)",
(rid, 2, 540, 1260), (rid, 2, 540, 1260),
@@ -102,15 +91,14 @@ def test_due_random_delivers_once(tmp_path, capsys):
finally: finally:
conn.close() conn.close()
# compute expected fire times for the date
from random_times import compute_fire_times from random_times import compute_fire_times
fires = compute_fire_times(datetime(2026, 6, 10).date(), "random reminder", {"times_per_day": 2, "window": "09:00-21:00"}) fires = compute_fire_times(date(2026, 6, 10), "random reminder", {"times_per_day": 2, "window": "09:00-21:00"})
assert len(fires) == 2 assert len(fires) == 2
for ft in fires: for ft in fires:
with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send: with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send:
_run_send(db_path, ft) _run_send(db_path, ft)
mock_send.assert_called_once_with("⏰ Reminder: random reminder") mock_send.assert_called_once_with("⏰ Reminder: random reminder", "token", "chat")
# all deduped now # all deduped now
for ft in fires: for ft in fires:
@@ -119,43 +107,53 @@ def test_due_random_delivers_once(tmp_path, capsys):
mock_send.assert_not_called() mock_send.assert_not_called()
def test_disabled_not_sent(tmp_path, capsys): def test_due_random_with_days_filter_delivers_on_allowed_day(tmp_path):
db_path = tmp_path / "test.sqlite" db_path = tmp_path / "test.sqlite"
init_db(db_path) init_db(db_path)
conn = get_db(db_path) conn = get_db(db_path)
try: try:
rid = _add_reminder(conn, "weekday only")
conn.execute( conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 0, 'Europe/Prague', 'now', 'now')", "INSERT INTO schedule_random (reminder_id, times_per_day, window_start, window_end, days_filter) "
("disabled reminder",), "VALUES (?, 1, 540, 1260, '1-5')",
) (rid,),
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)",
(rid, "2026-06-10T10:00:00"),
) )
finally: finally:
conn.close() conn.close()
from random_times import compute_fire_times
wednesday = date(2026, 6, 10)
fires = compute_fire_times(wednesday, "weekday only", {"times_per_day": 1, "window": "09:00-21:00", "days": "1-5"})
assert len(fires) == 1 # 2026-06-10 is a Wednesday, allowed by 1-5
with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send:
_run_send(db_path, fires[0])
mock_send.assert_called_once_with("⏰ Reminder: weekday only", "token", "chat")
def test_disabled_not_sent(tmp_path):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
conn = get_db(db_path)
try:
rid = _add_reminder(conn, "disabled reminder", enabled=0)
conn.execute("INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid, "2026-06-10T10:00:00"))
finally:
conn.close()
now = datetime(2026, 6, 10, 10, 0, 0) now = datetime(2026, 6, 10, 10, 0, 0)
with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send: with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send:
_run_send(db_path, now) _run_send(db_path, now)
mock_send.assert_not_called() mock_send.assert_not_called()
def test_deleted_not_sent(tmp_path, capsys): def test_deleted_not_sent(tmp_path):
db_path = tmp_path / "test.sqlite" db_path = tmp_path / "test.sqlite"
init_db(db_path) init_db(db_path)
conn = get_db(db_path) conn = get_db(db_path)
try: try:
conn.execute( rid = _add_reminder(conn, "deleted reminder", deleted_at="now")
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at, deleted_at) VALUES (?, 1, 'Europe/Prague', 'now', 'now', 'now')", conn.execute("INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid, "2026-06-10T10:00:00"))
("deleted reminder",),
)
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)",
(rid, "2026-06-10T10:00:00"),
)
finally: finally:
conn.close() conn.close()
@@ -165,20 +163,13 @@ def test_deleted_not_sent(tmp_path, capsys):
mock_send.assert_not_called() mock_send.assert_not_called()
def test_delivery_failure_logged(tmp_path, capsys): def test_delivery_failure_logged(tmp_path):
db_path = tmp_path / "test.sqlite" db_path = tmp_path / "test.sqlite"
init_db(db_path) init_db(db_path)
conn = get_db(db_path) conn = get_db(db_path)
try: try:
conn.execute( rid = _add_reminder(conn, "fail reminder")
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', 'now', 'now')", conn.execute("INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid, "2026-06-10T10:00:00"))
("fail reminder",),
)
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)",
(rid, "2026-06-10T10:00:00"),
)
finally: finally:
conn.close() conn.close()
@@ -198,6 +189,26 @@ def test_delivery_failure_logged(tmp_path, capsys):
conn.close() conn.close()
def test_failed_fire_retries(tmp_path):
"""A fire that failed (status='failed') is not deduped — the next run retries it."""
db_path = tmp_path / "test.sqlite"
init_db(db_path)
conn = get_db(db_path)
try:
rid = _add_reminder(conn, "retry me")
conn.execute("INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid, "2026-06-10T10:00:00"))
finally:
conn.close()
now = datetime(2026, 6, 10, 10, 0, 0)
with patch.object(remind_send, "_send_telegram", side_effect=RuntimeError("down")):
_run_send(db_path, now)
with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send:
_run_send(db_path, now)
mock_send.assert_called_once_with("⏰ Reminder: retry me", "token", "chat")
def test_schedule_type_correct_despite_id_collision(tmp_path): def test_schedule_type_correct_despite_id_collision(tmp_path):
"""A cron fire must record schedule_type='cron' even when schedule_cron.id collides """A cron fire must record schedule_type='cron' even when schedule_cron.id collides
with a schedule_at.id (each schedule table has its own AUTOINCREMENT sequence).""" with a schedule_at.id (each schedule table has its own AUTOINCREMENT sequence)."""
@@ -205,20 +216,10 @@ def test_schedule_type_correct_despite_id_collision(tmp_path):
init_db(db_path) init_db(db_path)
conn = get_db(db_path) conn = get_db(db_path)
try: try:
conn.execute( rid_at = _add_reminder(conn, "at one")
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES ('at one', 1, 'Europe/Prague', 'now', 'now')" conn.execute("INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid_at, "2030-01-01T00:00:00"))
) rid_cron = _add_reminder(conn, "cron one")
rid_at = conn.execute("SELECT last_insert_rowid()").fetchone()[0] conn.execute("INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)", (rid_cron, "0 10 * * *"))
conn.execute(
"INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid_at, "2030-01-01T00:00:00")
)
conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES ('cron one', 1, 'Europe/Prague', 'now', 'now')"
)
rid_cron = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)", (rid_cron, "0 10 * * *")
)
# schedule_at.id and schedule_cron.id both equal 1 here — the collision the fix guards against. # schedule_at.id and schedule_cron.id both equal 1 here — the collision the fix guards against.
assert conn.execute("SELECT id FROM schedule_at").fetchone()["id"] == 1 assert conn.execute("SELECT id FROM schedule_at").fetchone()["id"] == 1
assert conn.execute("SELECT id FROM schedule_cron").fetchone()["id"] == 1 assert conn.execute("SELECT id FROM schedule_cron").fetchone()["id"] == 1