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
**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.
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.

View File

@@ -26,7 +26,7 @@ This file documents non-obvious constraints and usage patterns.
## cron — Background Agent Tasks
- 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.
## 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
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
`YYYY-MM-DDTHH:MM:SS <text>`, Prague time, bez timezone suffixu). Posílá je
**systémový cron uživatele nanobot** (`skills/remind/scripts/remind_send.py`)
přímo přes Telegram, mimo agenta. Když se uživatel ptá na minulé/dnešní
připomínky („připomněl jsi mi…?", „co dnes přišlo?"), přečti tento soubor.
Vedle něj v `log/reminder_cron.log` se zachytává stdout/stderr crontabu —
za normálního běhu prázdný, plní se jen při pádech skriptu.
Připomínky doručuje **systémový cron uživatele nanobot**
(`skills/remind/scripts/remind_send.py`) přímo přes Telegram, mimo agenta —
agent u odeslání není. Když se uživatel ptá na minulé/dnešní připomínky
(„připomněl jsi mi…?", „co dnes přišlo?"), zavolej
`uv run skills/remind/scripts/remind_edit.py delivered [--since YYYY-MM-DD]`
čte tabulku `reminder_fires` (jen doručené, čas v Praze).
`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
```
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
```

View File

@@ -92,7 +92,7 @@ def init_db(path: Path) -> None:
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."""
workspace = Path(__file__).resolve().parent.parent.parent.parent
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.
"""
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
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
@@ -60,7 +60,7 @@ def _parse_count(raw: object) -> int:
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:
raise ValueError(f"window must be 'HH:MM-HH:MM', got {raw!r}")
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
# requires-python = ">=3.11"
# dependencies = ["croniter"]
@@ -21,7 +21,7 @@ from zoneinfo import ZoneInfo
from croniter import croniter
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
DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite"
@@ -57,19 +57,7 @@ def _build_random(args: argparse.Namespace) -> dict | None:
return cfg
def _parse_window(window: str) -> tuple[int, int]:
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:
def _insert_schedules(conn, reminder_id: int, args: argparse.Namespace, random_cfg: dict | None) -> None:
if args.at:
for at_str in args.at:
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 (?, ?)",
(reminder_id, expr),
)
random_cfg = _build_random(args)
if random_cfg:
start, end = _parse_window(random_cfg["window"])
start, end = parse_window(random_cfg["window"])
conn.execute(
"""
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]:
escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
rows = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at FROM reminders WHERE text LIKE ? AND deleted_at IS NULL",
(f"%{keyword}%",),
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at "
"FROM reminders WHERE text LIKE ? ESCAPE '\\' AND deleted_at IS NULL",
(f"%{escaped}%",),
).fetchall()
return [dict(r) for r in rows]
@@ -181,48 +170,52 @@ def cmd_list(_args: argparse.Namespace) -> int:
conn = get_db(DB_PATH)
try:
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()
if not rows:
print("(no active reminders)")
return 0
for idx, row in enumerate(rows, start=1):
reminder = dict(row)
rid = reminder["id"]
status = "enabled" if reminder["enabled"] else "disabled"
print(f"{idx}. {reminder['text']} ({status})")
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)}")
for row in rows:
rid = row["id"]
status = "enabled" if row["enabled"] else "disabled"
print(f"#{rid} {row['text']} [{status}]")
for line in _schedule_lines(conn, rid):
print(f" {line}")
return 0
finally:
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:
text = (args.text or "").strip()
if not text:
@@ -263,7 +256,7 @@ def cmd_add(args: argparse.Namespace) -> int:
(text, now, now),
)
reminder_id = cur.lastrowid
_insert_schedules(conn, reminder_id, args)
_insert_schedules(conn, reminder_id, args, random_cfg)
conn.execute("COMMIT")
reminder = _fetch_reminder(conn, reminder_id)
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)
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()
conn = get_db(DB_PATH)
try:
@@ -317,11 +323,7 @@ def cmd_edit(args: argparse.Namespace) -> int:
conn.execute("BEGIN")
now = _now()
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
if new_text is not None:
conn.execute("UPDATE reminders SET text = ?, updated_at = ? WHERE id = ?", (new_text, now, rid))
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_cron 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))
log_operation("EDIT", rid, "schedules replaced")
@@ -403,21 +405,28 @@ def cmd_delivered(args: argparse.Namespace) -> int:
except ValueError as exc:
print(json.dumps({"error": f"invalid --since date: {exc}"}), file=sys.stderr)
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:
today = datetime.now(PRAGUE).date().isoformat()
where, params = "substr(f.fire_time, 1, 10) = ?", (today,)
rows = conn.execute(
f"""
SELECT f.delivered_at, r.text
FROM reminder_fires f
JOIN reminders r ON r.id = f.reminder_id
WHERE f.status = 'delivered' AND {where}
ORDER BY f.delivered_at
""",
params,
).fetchall()
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 substr(f.fire_time, 1, 10) = ?
ORDER BY f.delivered_at
""",
(today,),
).fetchall()
for row in rows:
print(f"{row['delivered_at']} {row['text']}")
return 0

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["croniter"]
@@ -44,8 +44,7 @@ def _telegram_config() -> tuple[str, str]:
return telegram["token"], chat_id
def _send_telegram(text: str) -> None:
token, chat_id = _telegram_config()
def _send_telegram(text: str, token: str, chat_id: str) -> None:
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode()
req = urllib.request.Request(url, data=payload, method="POST")
@@ -53,7 +52,7 @@ def _send_telegram(text: str) -> None:
resp.read()
def _now() -> datetime:
def _now_prague() -> datetime:
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:
now = _now().isoformat(timespec="seconds")
now = _now_prague().isoformat(timespec="seconds")
conn.execute(
"""
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)
try:
now = _now()
now = _now_prague()
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:
text = fire["text"]
rid = fire["id"]
@@ -197,7 +199,7 @@ def main() -> None:
schedule_type = fire["schedule_type"]
try:
_send_telegram(f"⏰ Reminder: {text}")
_send_telegram(f"⏰ Reminder: {text}", token, chat_id)
except Exception as e:
print(f"remind_send: delivery failed for {text!r}: {e}", file=sys.stderr)
_record_fire(conn, rid, sid, schedule_type, ft, "failed", str(e))

View File

@@ -1,11 +1,8 @@
import json
import os
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
import pytest
SCRIPTS = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS))
@@ -39,7 +36,7 @@ def test_add_list(tmp_path, capsys):
ret = _run(db_path, ["list"])
captured = capsys.readouterr()
assert ret == 0
assert "drink water" in captured.out
assert "#1 drink water [enabled]" 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"])
captured = capsys.readouterr()
assert captured.out.strip() == ""
assert "(no active reminders)" in captured.out
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
# failed fire is not reported as delivered
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
from datetime import datetime, timedelta
from datetime import date, datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from unittest.mock import patch
SCRIPTS = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS))
@@ -16,40 +11,45 @@ import remind_send
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_now = remind_send._now_prague
original_config = remind_send._telegram_config
try:
remind_send.DB_PATH = db_path
remind_send._telegram_config = lambda: ("token", "chat")
if now is not None:
remind_send._now = lambda: now
remind_send._now_prague = lambda: now
remind_send.main()
finally:
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"
init_db(db_path)
conn = get_db(db_path)
try:
conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', 'now', 'now')",
("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),
)
rid = _add_reminder(conn, "at reminder")
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", return_value=None) as mock_send:
_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
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()
def test_due_cron_delivers_once(tmp_path, capsys):
def test_due_cron_delivers_once(tmp_path):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
conn = get_db(db_path)
try:
conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', 'now', 'now')",
("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 * * *"),
)
rid = _add_reminder(conn, "cron reminder")
conn.execute("INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)", (rid, "0 10 * * *"))
finally:
conn.close()
now = datetime(2026, 6, 10, 10, 0, 0)
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: cron reminder")
mock_send.assert_called_once_with("⏰ Reminder: cron reminder", "token", "chat")
# second run — dedup
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()
def test_due_random_delivers_once(tmp_path, capsys):
def test_due_random_delivers_once(tmp_path):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
conn = get_db(db_path)
try:
conn.execute(
"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]
rid = _add_reminder(conn, "random reminder")
conn.execute(
"INSERT INTO schedule_random (reminder_id, times_per_day, window_start, window_end) VALUES (?, ?, ?, ?)",
(rid, 2, 540, 1260),
@@ -102,15 +91,14 @@ def test_due_random_delivers_once(tmp_path, capsys):
finally:
conn.close()
# compute expected fire times for the date
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
for ft in fires:
with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send:
_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
for ft in fires:
@@ -119,43 +107,53 @@ def test_due_random_delivers_once(tmp_path, capsys):
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"
init_db(db_path)
conn = get_db(db_path)
try:
rid = _add_reminder(conn, "weekday only")
conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 0, 'Europe/Prague', 'now', 'now')",
("disabled 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"),
"INSERT INTO schedule_random (reminder_id, times_per_day, window_start, window_end, days_filter) "
"VALUES (?, 1, 540, 1260, '1-5')",
(rid,),
)
finally:
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)
with patch.object(remind_send, "_send_telegram", return_value=None) as mock_send:
_run_send(db_path, now)
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"
init_db(db_path)
conn = get_db(db_path)
try:
conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at, deleted_at) VALUES (?, 1, 'Europe/Prague', 'now', 'now', 'now')",
("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"),
)
rid = _add_reminder(conn, "deleted reminder", deleted_at="now")
conn.execute("INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid, "2026-06-10T10:00:00"))
finally:
conn.close()
@@ -165,20 +163,13 @@ def test_deleted_not_sent(tmp_path, capsys):
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"
init_db(db_path)
conn = get_db(db_path)
try:
conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', 'now', 'now')",
("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"),
)
rid = _add_reminder(conn, "fail reminder")
conn.execute("INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)", (rid, "2026-06-10T10:00:00"))
finally:
conn.close()
@@ -198,6 +189,26 @@ def test_delivery_failure_logged(tmp_path, capsys):
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):
"""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)."""
@@ -205,20 +216,10 @@ def test_schedule_type_correct_despite_id_collision(tmp_path):
init_db(db_path)
conn = get_db(db_path)
try:
conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES ('at one', 1, 'Europe/Prague', 'now', 'now')"
)
rid_at = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
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 * * *")
)
rid_at = _add_reminder(conn, "at one")
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")
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.
assert conn.execute("SELECT id FROM schedule_at").fetchone()["id"] == 1
assert conn.execute("SELECT id FROM schedule_cron").fetchone()["id"] == 1