claude cisteni skillu remind

This commit is contained in:
lachtan
2026-06-10 08:35:31 +02:00
parent addd153e8f
commit edf823fc72
5 changed files with 261 additions and 88 deletions

View File

@@ -7,6 +7,9 @@ description: >
# /remind # /remind
Reminders are stored in SQLite (`db/reminders.sqlite`) and delivered by the nanobot
user crontab, directly to Telegram, outside the agent. Reply to the user in their own language.
## Commands ## Commands
### Create ### Create
@@ -35,7 +38,7 @@ Call `remind_edit.py list` → JSON with all active reminders.
/remind edit <keyword> --text "new text" /remind edit <keyword> --text "new text"
/remind edit <keyword> --replace-schedules --cron "0 10 * * *" /remind edit <keyword> --replace-schedules --cron "0 10 * * *"
``` ```
Call `remind_edit.py edit --keyword <keyword>`. Keyword matches case-insensitively against reminder text. Ambiguous matches are rejected. Call `remind_edit.py edit --keyword <keyword>`. Keyword matches case-insensitively against reminder text. `--replace-schedules` requires at least one new `--cron`/`--at`/`--random-*`.
### Enable / Disable ### Enable / Disable
``` ```
@@ -49,6 +52,16 @@ Call `remind_edit.py edit --keyword <keyword>`. Keyword matches case-insensitive
``` ```
Soft delete. Hard delete only via direct DB access. Soft delete. Hard delete only via direct DB access.
### Selecting by keyword or id
`edit`, `remove`, `enable`, `disable` accept either `--keyword` (case-insensitive substring) or `--id` (exact). A keyword matching two or more reminders returns `ambiguous` with each id — retry with `--id <n>` to disambiguate duplicate texts. Run `list` to see ids.
### Delivered (history)
```
/remind delivered
/remind delivered --since 2026-06-01
```
Answers "what reminders arrived today / since when?". Calls `remind_edit.py delivered`, which reads the `reminder_fires` table (delivered rows only, Prague local time). The agent never sees deliveries happen, so this is the only window into them.
## Scripts ## Scripts
| Action | Command | | Action | Command |
@@ -59,10 +72,11 @@ Soft delete. Hard delete only via direct DB access.
| remove | `uv run skills/remind/scripts/remind_edit.py remove --keyword <kw>` | | remove | `uv run skills/remind/scripts/remind_edit.py remove --keyword <kw>` |
| enable | `uv run skills/remind/scripts/remind_edit.py enable --keyword <kw>` | | enable | `uv run skills/remind/scripts/remind_edit.py enable --keyword <kw>` |
| disable | `uv run skills/remind/scripts/remind_edit.py disable --keyword <kw>` | | disable | `uv run skills/remind/scripts/remind_edit.py disable --keyword <kw>` |
| delivered | `uv run skills/remind/scripts/remind_edit.py delivered [--since YYYY-MM-DD]` |
Sender runs every minute from crontab: `uv run skills/remind/scripts/remind_send.py` Sender runs every minute from crontab: `uv run skills/remind/scripts/remind_send.py`
## Environment ## Environment
- `REMIND_DB` — override SQLite path (used in tests). - `REMIND_DB` — override SQLite path (used in tests).
- `python3` required; `python` unavailable. - Scripts run via `uv run` (PEP 723 headers declare their dependencies).

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# /// script # /// script
# requires-python = ">=3.11" # requires-python = ">=3.11"
# dependencies = ["croniter", "pyyaml"] # dependencies = ["croniter"]
# /// # ///
"""Deterministic CRUD for reminders backed by SQLite. """Deterministic CRUD for reminders backed by SQLite.
@@ -15,8 +15,9 @@ import argparse
import json import json
import os import os
import sys import sys
from datetime import datetime, timezone from datetime import date, datetime, timezone
from pathlib import Path from pathlib import Path
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
@@ -25,6 +26,7 @@ from random_times import compute_fire_times
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"
DB_PATH = Path(os.environ.get("REMIND_DB", str(DEFAULT_DB_PATH))) DB_PATH = Path(os.environ.get("REMIND_DB", str(DEFAULT_DB_PATH)))
PRAGUE = ZoneInfo("Europe/Prague")
def _now() -> str: def _now() -> str:
@@ -51,7 +53,7 @@ def _build_random(args: argparse.Namespace) -> dict | None:
raise ValueError("random schedule needs --random-times-per-day and --random-window") raise ValueError("random schedule needs --random-times-per-day and --random-window")
cfg = {key: value for key, value in fields.items() if value is not None} cfg = {key: value for key, value in fields.items() if value is not None}
compute_fire_times(__import__("datetime").date(2000, 1, 1), "validation", cfg) compute_fire_times(date(2000, 1, 1), "validation", cfg)
return cfg return cfg
@@ -136,6 +138,44 @@ def _find_by_keyword(conn, keyword: str) -> list[dict]:
return [dict(r) for r in rows] return [dict(r) for r in rows]
def _resolve_one(conn, args: argparse.Namespace) -> dict | None:
"""Resolve exactly one active reminder by --id (exact) or --keyword (substring).
Prints a JSON error to stderr and returns None when no/ambiguous match. Ambiguous
matches include each id so the caller can retry with --id.
"""
rid = getattr(args, "id", None)
if rid is not None:
row = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at "
"FROM reminders WHERE id = ? AND deleted_at IS NULL",
(rid,),
).fetchone()
if row is None:
print(json.dumps({"error": "no match", "id": rid}), file=sys.stderr)
return None
return dict(row)
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "provide --id or --keyword"}), file=sys.stderr)
return None
matches = _find_by_keyword(conn, keyword)
if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return None
if len(matches) > 1:
print(
json.dumps(
{"error": "ambiguous", "matches": [{"id": m["id"], "text": m["text"]} for m in matches]},
ensure_ascii=False,
),
file=sys.stderr,
)
return None
return matches[0]
def cmd_list(_args: argparse.Namespace) -> int: def cmd_list(_args: argparse.Namespace) -> int:
_ensure_db() _ensure_db()
conn = get_db(DB_PATH) conn = get_db(DB_PATH)
@@ -238,30 +278,18 @@ def cmd_add(args: argparse.Namespace) -> int:
def cmd_remove(args: argparse.Namespace) -> int: def cmd_remove(args: argparse.Namespace) -> int:
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
return 1
_ensure_db() _ensure_db()
conn = get_db(DB_PATH) conn = get_db(DB_PATH)
try: try:
matches = _find_by_keyword(conn, keyword) target = _resolve_one(conn, args)
if len(matches) == 0: if target is None:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
return 1 return 1
rid = matches[0]["id"] rid = target["id"]
conn.execute("BEGIN") conn.execute("BEGIN")
conn.execute("UPDATE reminders SET deleted_at = ?, updated_at = ? WHERE id = ?", (_now(), _now(), rid)) conn.execute("UPDATE reminders SET deleted_at = ?, updated_at = ? WHERE id = ?", (_now(), _now(), rid))
conn.execute("COMMIT") conn.execute("COMMIT")
log_operation("REMOVE", rid, f'text="{matches[0]["text"]}"') log_operation("REMOVE", rid, f'text="{target["text"]}"')
reminder = _fetch_reminder(conn, rid) reminder = _fetch_reminder(conn, rid)
print(json.dumps({"removed": reminder}, ensure_ascii=False)) print(json.dumps({"removed": reminder}, ensure_ascii=False))
return 0 return 0
@@ -274,26 +302,18 @@ def cmd_remove(args: argparse.Namespace) -> int:
def cmd_edit(args: argparse.Namespace) -> int: def cmd_edit(args: argparse.Namespace) -> int:
keyword = (args.keyword or "").strip().lower() if args.replace_schedules and not (args.at or args.cron or args.random_times_per_day or args.random_window):
if not keyword: print(json.dumps({"error": "--replace-schedules requires at least one --cron/--at/--random-* option"}), file=sys.stderr)
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
return 1 return 1
_ensure_db() _ensure_db()
conn = get_db(DB_PATH) conn = get_db(DB_PATH)
try: try:
matches = _find_by_keyword(conn, keyword) target = _resolve_one(conn, args)
if len(matches) == 0: if target is None:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
return 1 return 1
rid = matches[0]["id"] rid = target["id"]
conn.execute("BEGIN") conn.execute("BEGIN")
now = _now() now = _now()
@@ -326,26 +346,14 @@ def cmd_edit(args: argparse.Namespace) -> int:
def cmd_enable(args: argparse.Namespace) -> int: def cmd_enable(args: argparse.Namespace) -> int:
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
return 1
_ensure_db() _ensure_db()
conn = get_db(DB_PATH) conn = get_db(DB_PATH)
try: try:
matches = _find_by_keyword(conn, keyword) target = _resolve_one(conn, args)
if len(matches) == 0: if target is None:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
return 1 return 1
rid = matches[0]["id"] rid = target["id"]
conn.execute("UPDATE reminders SET enabled = 1, updated_at = ? WHERE id = ?", (_now(), rid)) conn.execute("UPDATE reminders SET enabled = 1, updated_at = ? WHERE id = ?", (_now(), rid))
log_operation("ENABLE", rid, None) log_operation("ENABLE", rid, None)
reminder = _fetch_reminder(conn, rid) reminder = _fetch_reminder(conn, rid)
@@ -359,26 +367,14 @@ def cmd_enable(args: argparse.Namespace) -> int:
def cmd_disable(args: argparse.Namespace) -> int: def cmd_disable(args: argparse.Namespace) -> int:
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
return 1
_ensure_db() _ensure_db()
conn = get_db(DB_PATH) conn = get_db(DB_PATH)
try: try:
matches = _find_by_keyword(conn, keyword) target = _resolve_one(conn, args)
if len(matches) == 0: if target is None:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
return 1 return 1
rid = matches[0]["id"] rid = target["id"]
conn.execute("UPDATE reminders SET enabled = 0, updated_at = ? WHERE id = ?", (_now(), rid)) conn.execute("UPDATE reminders SET enabled = 0, updated_at = ? WHERE id = ?", (_now(), rid))
log_operation("DISABLE", rid, None) log_operation("DISABLE", rid, None)
reminder = _fetch_reminder(conn, rid) reminder = _fetch_reminder(conn, rid)
@@ -391,6 +387,44 @@ def cmd_disable(args: argparse.Namespace) -> int:
conn.close() conn.close()
def cmd_delivered(args: argparse.Namespace) -> int:
"""List reminders actually delivered to the user, newest last.
Answers 'what reminders arrived today?'. fire_time/delivered_at are stored in
Prague local time, so no conversion is needed. Defaults to today (Prague).
"""
_ensure_db()
conn = get_db(DB_PATH)
try:
since = (args.since or "").strip()
if since:
try:
date.fromisoformat(since)
except ValueError as exc:
print(json.dumps({"error": f"invalid --since date: {exc}"}), file=sys.stderr)
return 1
where, params = "f.fire_time >= ?", (since,)
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()
for row in rows:
print(f"{row['delivered_at']} {row['text']}")
return 0
finally:
conn.close()
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="CRUD for reminders (SQLite backed)") parser = argparse.ArgumentParser(description="CRUD for reminders (SQLite backed)")
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
@@ -407,11 +441,13 @@ def main() -> None:
add_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD", help="Random schedule: start date") add_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD", help="Random schedule: start date")
add_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD", help="Random schedule: end date") add_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD", help="Random schedule: end date")
remove_p = sub.add_parser("remove", help="Remove a reminder by keyword (soft delete)") remove_p = sub.add_parser("remove", help="Remove a reminder by keyword or id (soft delete)")
remove_p.add_argument("--keyword", required=True, help="Substring to match against reminder text") remove_p.add_argument("--keyword", help="Substring to match against reminder text")
remove_p.add_argument("--id", type=int, help="Exact reminder id (disambiguates duplicate texts)")
edit_p = sub.add_parser("edit", help="Edit a reminder by keyword") edit_p = sub.add_parser("edit", help="Edit a reminder by keyword or id")
edit_p.add_argument("--keyword", required=True, help="Substring to match against reminder text") edit_p.add_argument("--keyword", help="Substring to match against reminder text")
edit_p.add_argument("--id", type=int, help="Exact reminder id (disambiguates duplicate texts)")
edit_p.add_argument("--text", help="New reminder text") edit_p.add_argument("--text", help="New reminder text")
edit_p.add_argument("--replace-schedules", action="store_true", help="Replace all schedules with new ones") edit_p.add_argument("--replace-schedules", action="store_true", help="Replace all schedules with new ones")
edit_p.add_argument("--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)") edit_p.add_argument("--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)")
@@ -422,11 +458,16 @@ def main() -> None:
edit_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD") edit_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD")
edit_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD") edit_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD")
enable_p = sub.add_parser("enable", help="Enable a reminder by keyword") enable_p = sub.add_parser("enable", help="Enable a reminder by keyword or id")
enable_p.add_argument("--keyword", required=True) enable_p.add_argument("--keyword")
enable_p.add_argument("--id", type=int, help="Exact reminder id")
disable_p = sub.add_parser("disable", help="Disable a reminder by keyword") disable_p = sub.add_parser("disable", help="Disable a reminder by keyword or id")
disable_p.add_argument("--keyword", required=True) disable_p.add_argument("--keyword")
disable_p.add_argument("--id", type=int, help="Exact reminder id")
delivered_p = sub.add_parser("delivered", help="List reminders delivered to the user (default: today)")
delivered_p.add_argument("--since", metavar="YYYY-MM-DD", help="List deliveries on/after this date instead of today")
args = parser.parse_args() args = parser.parse_args()
dispatch = { dispatch = {
@@ -436,6 +477,7 @@ def main() -> None:
"edit": cmd_edit, "edit": cmd_edit,
"enable": cmd_enable, "enable": cmd_enable,
"disable": cmd_disable, "disable": cmd_disable,
"delivered": cmd_delivered,
} }
return dispatch[args.command](args) return dispatch[args.command](args)

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# /// script # /// script
# requires-python = ">=3.11" # requires-python = ">=3.11"
# dependencies = ["croniter", "pyyaml"] # dependencies = ["croniter"]
# /// # ///
"""Deterministic reminder sender backed by SQLite. """Deterministic reminder sender backed by SQLite.
@@ -21,7 +21,6 @@ from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import yaml
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
@@ -32,19 +31,23 @@ DB_PATH = Path(os.environ.get("REMIND_DB", str(DEFAULT_DB_PATH)))
CONFIG = Path.home() / ".nanobot" / "config.json" CONFIG = Path.home() / ".nanobot" / "config.json"
TZ = ZoneInfo("Europe/Prague") TZ = ZoneInfo("Europe/Prague")
CHAT_ID = "8826147089"
TOLERANCE_SECONDS = 60 TOLERANCE_SECONDS = 60
FALLBACK_CHAT_ID = "8826147089"
def _telegram_token() -> str: def _telegram_config() -> tuple[str, str]:
"""Return (bot token, chat id). Chat id reads channels.telegram.allowFrom[0], with a constant fallback."""
data = json.loads(CONFIG.read_text(encoding="utf-8")) data = json.loads(CONFIG.read_text(encoding="utf-8"))
return data["channels"]["telegram"]["token"] telegram = data["channels"]["telegram"]
allow_from = telegram.get("allowFrom") or []
chat_id = str(allow_from[0]) if allow_from else FALLBACK_CHAT_ID
return telegram["token"], chat_id
def _send_telegram(text: str) -> None: def _send_telegram(text: str) -> None:
token = _telegram_token() 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")
with urllib.request.urlopen(req, timeout=15) as resp: with urllib.request.urlopen(req, timeout=15) as resp:
resp.read() resp.read()
@@ -75,7 +78,7 @@ def _due_at(conn, now: datetime) -> list[dict]:
""", """,
(since, until), (since, until),
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [{**dict(r), "schedule_type": "at"} for r in rows]
def _due_cron(conn, now: datetime) -> list[dict]: def _due_cron(conn, now: datetime) -> list[dict]:
@@ -107,6 +110,7 @@ def _due_cron(conn, now: datetime) -> list[dict]:
"text": row["text"], "text": row["text"],
"schedule_id": row["schedule_id"], "schedule_id": row["schedule_id"],
"fire_time": fire_iso, "fire_time": fire_iso,
"schedule_type": "cron",
}) })
return due return due
@@ -156,6 +160,7 @@ def _due_random(conn, now: datetime) -> list[dict]:
"text": row["text"], "text": row["text"],
"schedule_id": row["schedule_id"], "schedule_id": row["schedule_id"],
"fire_time": fire_iso, "fire_time": fire_iso,
"schedule_type": "random",
}) })
return due return due
@@ -189,14 +194,7 @@ def main() -> None:
rid = fire["id"] rid = fire["id"]
sid = fire["schedule_id"] sid = fire["schedule_id"]
ft = fire["fire_time"] ft = fire["fire_time"]
# Determine schedule_type from which query produced it schedule_type = fire["schedule_type"]
# We can infer: if 'schedule_id' came from schedule_at, it's 'at'
# But we don't have that info here. Let's look it up.
st = conn.execute(
"SELECT 'at' FROM schedule_at WHERE id = ? UNION ALL SELECT 'cron' FROM schedule_cron WHERE id = ? UNION ALL SELECT 'random' FROM schedule_random WHERE id = ?",
(sid, sid, sid),
).fetchone()
schedule_type = st[0] if st else "unknown"
try: try:
_send_telegram(f"⏰ Reminder: {text}") _send_telegram(f"⏰ Reminder: {text}")

View File

@@ -180,3 +180,80 @@ def test_add_random_validation(tmp_path, capsys):
captured = capsys.readouterr() captured = capsys.readouterr()
assert ret == 1 assert ret == 1
assert "window" in captured.err.lower() or "gap" in captured.err.lower() assert "window" in captured.err.lower() or "gap" in captured.err.lower()
def test_remove_by_id_disambiguates_duplicates(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
_run(db_path, ["add", "--text", "drink water", "--cron", "0 9 * * *"])
_run(db_path, ["add", "--text", "drink water", "--cron", "0 10 * * *"])
capsys.readouterr()
ret = _run(db_path, ["remove", "--keyword", "drink water"])
captured = capsys.readouterr()
assert ret == 1
assert "ambiguous" in captured.err
ret = _run(db_path, ["remove", "--id", "1"])
captured = capsys.readouterr()
assert ret == 0
assert json.loads(captured.out)["removed"]["id"] == 1
# the second duplicate is still there
ret = _run(db_path, ["list"])
captured = capsys.readouterr()
assert "drink water" in captured.out
def test_resolve_requires_id_or_keyword(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
ret = _run(db_path, ["remove"])
captured = capsys.readouterr()
assert ret == 1
assert "id or --keyword" in captured.err
def test_edit_replace_schedules_requires_new_schedule(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
_run(db_path, ["add", "--text", "task", "--cron", "0 9 * * *"])
capsys.readouterr()
ret = _run(db_path, ["edit", "--keyword", "task", "--replace-schedules"])
captured = capsys.readouterr()
assert ret == 1
assert "replace-schedules" in captured.err
def test_delivered_lists_deliveries(tmp_path, capsys):
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 ('took 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', '2026-06-01T09:00:00', '2026-06-01T09:00:01', 'delivered')",
(rid,),
)
conn.execute(
"INSERT INTO reminder_fires (reminder_id, schedule_id, schedule_type, fire_time, delivered_at, status, error_message) "
"VALUES (?, 1, 'cron', '2026-06-01T10:00:00', NULL, 'failed', 'boom')",
(rid,),
)
finally:
conn.close()
ret = _run(db_path, ["delivered", "--since", "2026-06-01"])
captured = capsys.readouterr()
assert ret == 0
assert "took pills" in captured.out
assert "2026-06-01T09:00:01" in captured.out
# failed fire is not reported as delivered
assert captured.out.count("took pills") == 1

View File

@@ -196,3 +196,45 @@ def test_delivery_failure_logged(tmp_path, capsys):
assert "network down" in row["error_message"] assert "network down" in row["error_message"]
finally: finally:
conn.close() conn.close()
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)."""
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 ('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 * * *")
)
# 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
finally:
conn.close()
now = datetime(2026, 6, 10, 10, 0, 0)
with patch.object(remind_send, "_send_telegram", return_value=None):
_run_send(db_path, now)
conn = get_db(db_path)
try:
row = conn.execute(
"SELECT schedule_type, status FROM reminder_fires WHERE reminder_id = ?", (rid_cron,)
).fetchone()
assert row["schedule_type"] == "cron"
assert row["status"] == "delivered"
finally:
conn.close()