migrace /remind na sqlite

This commit is contained in:
lachtan
2026-06-10 07:10:11 +02:00
parent be01fe7a07
commit b2d367e6a4
8 changed files with 1315 additions and 231 deletions

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///
"""Migrate reminders from reminder.yaml to SQLite.
Reads reminder.yaml, inserts into reminders.sqlite, then renames YAML to .bak.
"""
from __future__ import annotations
import sys
from datetime import datetime, timezone
from os import environ
from pathlib import Path
# db.py lives in skills/remind/scripts/
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "skills" / "remind" / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
import yaml
from db import get_db, init_db
WORKSPACE = Path(__file__).resolve().parent.parent
YAML_PATH = WORKSPACE / "reminder.yaml"
DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite"
DB_PATH = Path(environ.get("REMIND_DB", str(DEFAULT_DB_PATH)))
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
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 main() -> int:
if not YAML_PATH.exists():
print("No reminder.yaml found — nothing to migrate.")
return 0
data = yaml.safe_load(YAML_PATH.read_text(encoding="utf-8")) or {}
reminders = data.get("reminders", [])
if not reminders:
print("reminder.yaml is empty — nothing to migrate.")
return 0
init_db(DB_PATH)
conn = get_db(DB_PATH)
try:
conn.execute("BEGIN")
for item in reminders:
text = (item.get("text") or "").strip()
if not text:
continue
now = _now()
cur = conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', ?, ?)",
(text, now, now),
)
rid = cur.lastrowid
at = item.get("at")
at_times = item.get("at_times", [])
if at:
at_times = [at] + list(at_times)
for at_str in at_times:
conn.execute(
"INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)",
(rid, at_str),
)
for expr in item.get("cron_exprs", []):
conn.execute(
"INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)",
(rid, expr),
)
random_cfg = item.get("random")
if random_cfg:
start, end = _parse_window(random_cfg["window"])
conn.execute(
"""
INSERT INTO schedule_random
(reminder_id, times_per_day, window_start, window_end, days_filter, from_date, until_date)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
rid,
random_cfg["times_per_day"],
start,
end,
random_cfg.get("days"),
random_cfg.get("from"),
random_cfg.get("until"),
),
)
conn.execute("COMMIT")
backup = YAML_PATH.with_suffix(".yaml.bak")
YAML_PATH.rename(backup)
print(f"Migrated {len(reminders)} reminders to {DB_PATH}")
print(f"Renamed {YAML_PATH} to {backup}")
return 0
except Exception as exc:
conn.execute("ROLLBACK")
print(f"Migration failed: {exc}", file=sys.stderr)
return 1
finally:
conn.close()
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,71 +1,92 @@
--- # /remind
name: remind
description: >-
Create recurring reminders for tasks. Use when the user wants to set up a
reminder for something they need to do regularly, or when they mention tasks
they keep forgetting. Also handles listing and removing reminders. Triggers on
words like "remind", "reminder".
---
# Remind Create, list, edit, enable, disable, and remove recurring or one-time reminders.
Create, list, and manage recurring reminders for tasks. ## How it works
## CRUD Script - **Storage**: SQLite (`db/reminders.sqlite`) — atomic transactions, no YAML races.
- **Schema**: `reminders` (text, enabled, timezone, timestamps, soft-delete) + `schedule_at` / `schedule_cron` / `schedule_random` + `reminder_fires` (dedup + audit).
- **Sender**: `remind_send.py` runs every minute from the user crontab. Reads SQLite, finds due fires, sends to Telegram, logs delivery.
- **Deduplication**: Every delivery is recorded in `reminder_fires` with status `delivered`/`failed`. One-time `at` reminders fire exactly once; cron and random fire once per computed slot.
- **Audit**: All mutations and deliveries are logged to `log/reminder.log`.
All mutations to `reminder.yaml` go through `scripts/remind_edit.py` (paths in this skill are relative to the skill directory). ## Commands
Run via: `uv run scripts/remind_edit.py <subcommand>` ### Create a reminder
Subcommands: ```
/remind drink water every day at 9:00
/remind stand up every weekday at 9:30
/remind buy milk at 2026-06-15T18:00
/remind stretch randomly 2 times between 08:00 and 20:00
/remind občanka pana Přibyla once daily at random time between 8:00 and 21:00
```
- **`list`** — prints JSON `{"reminders": [...]}`. The LLM parses natural language and calls `remind_edit.py add` with the appropriate flags:
- **`add --text "..." --cron "EXPR" [--cron "EXPR"]`** — add recurring reminder; validates cron syntax.
- **`add --text "..." --at "ISO_DATETIME" [--at "ISO_DATETIME"]`** — add one-time reminder(s); `--at` is repeatable.
- **`add --text "..." --at "ISO" --cron "EXPR"`** — combine one-time and recurring times in one entry.
- **`add --text "..." --random-times-per-day N --random-window "HH:MM-HH:MM" [--random-days "1-5"] [--random-from "YYYY-MM-DD"] [--random-until "YYYY-MM-DD"]`** — random but deterministic times: fires `N` times per day at random moments inside the window. Use when the user wants something a few times a day without a fixed clock time (e.g. "remind me to drink water a few times during the day"). `--random-days` is a cron day-of-week filter; `--random-from` / `--random-until` bound the active period. Minimum gap between fires is a fixed constant in `scripts/random_times.py`. Combinable with `--at` / `--cron`.
- **`remove --keyword "..."`** — removes by case-insensitive substring match. Returns error JSON if 0 or >1 matches.
All outputs are JSON. Errors go to stderr with non-zero exit code. - `--text "..."`
- `--cron "0 9 * * *"` (repeatable)
- `--at "2026-06-15T18:00:00"` (repeatable)
- `--random-times-per-day N --random-window HH:MM-HH:MM [--random-days DOW] [--random-from YYYY-MM-DD] [--random-until YYYY-MM-DD]`
## Create Workflow ### List reminders
1. **Identify the task** — What does the user want to be reminded about? If unclear, ask. ```
2. **Check for duplicates** — Run `remind_edit.py list` and compare existing reminder texts against the new one. If a similar reminder already exists: /remind list
- Show the user the existing reminder ```
- Ask whether they really want a duplicate, or want to modify the existing one
- Only proceed if the user explicitly confirms
3. **Determine frequency** — Ask how often the reminder should fire. Suggest common options:
- Every N minutes/hours/days
- Specific time of day (e.g. "every weekday at 9am")
- Specific day of week/month
- One-time at a specific datetime
- A few times a day at random moments (use the `--random-*` flags)
4. **Create the cron expression(s) or `at` field** — Map user input to cron syntax for recurring reminders, or ISO datetime for one-time reminders.
5. **Add via script** — Run a single `add` call combining all times (see CRUD Script for the exact flags). **Never call `add` multiple times for the same task** — put all times into one call.
6. **Confirm** — Show the user what was created (text, schedule).
## List Workflow Calls `remind_edit.py list` → JSON with all active reminders and their schedules.
1. Run `uv run remind_edit.py list` and parse the JSON output. ### Edit a reminder
2. Present all reminders in a table with columns: number, task, schedule.
3. Convert each schedule to human-readable text **in the user's language** (e.g. "every day at 9:00", "every Tuesday at 9:00"). For a `random` block, describe it like "5× a day at random between 9:0021:00, MonFri" (include `days`/`from`/`until` only if present).
4. If `reminders` is empty, say so.
## Remove / Done Workflow ```
/remind edit keyword --text "new text"
/remind edit keyword --replace-schedules --cron "0 10 * * *"
```
1. Run `uv run remind_edit.py remove --keyword "..."`. Calls `remind_edit.py edit --keyword <keyword>`. Keyword is matched case-insensitively against reminder text. Ambiguous matches are rejected.
2. If exit code is non-zero, read the error JSON:
- `"no match"` → tell the user no reminder matches the keyword.
- `"ambiguous"` → show the matches and ask the user to be more specific.
3. If success, confirm what was removed.
## Rules ### Enable / Disable
- **Respond to the user in their own language** (e.g. Czech) — this skill is written in English, but user-facing messages adapt to the user's language. ```
- **Never edit `reminder.yaml` directly** — no `edit_file`, `write_file`, or any direct write. All mutations go exclusively through `scripts/remind_edit.py`. /remind disable keyword
- **Read via the `list` subcommand** — never read the YAML file directly; always `remind_edit.py list`. /remind enable keyword
- Always confirm the reminder text and frequency with the user before creating. ```
- When listing, always show a human-readable schedule.
- Completed or removed reminders are deleted from `reminder.yaml` entirely — no `done` field, no `status` field. ### Remove a reminder
- Timezone is always `Europe/Prague` unless the user explicitly requests otherwise.
```
/remind remove keyword
```
Soft delete (sets `deleted_at`). Hard delete happens only via direct DB access.
## Files
| File | Purpose |
|------|---------|
| `skills/remind/scripts/db.py` | Schema, connection factory (`get_db`), `init_db()`, audit `log_operation()` |
| `skills/remind/scripts/remind_edit.py` | CRUD CLI: `list`, `add`, `remove`, `edit`, `enable`, `disable` |
| `skills/remind/scripts/remind_send.py` | Sender: reads SQLite, finds due fires, sends Telegram, dedups |
| `skills/remind/scripts/random_times.py` | Deterministic random time generator (seeded by text + date) |
| `scripts/migrate_yaml_to_sqlite.py` | One-shot migration from old `reminder.yaml` to SQLite |
| `skills/remind/tests/` | pytest suite: `test_db.py`, `test_remind_edit.py`, `test_remind_send.py`, `test_random_times.py` |
## Crontab
```
* * * * * uv run /home/nanobot/.nanobot/workspace/skills/remind/scripts/remind_send.py >> /home/nanobot/.nanobot/workspace/log/reminder_cron.log 2>&1
```
## Environment
- `REMIND_DB` — override SQLite path (used in tests).
- `python3` is required; `python` is not available in this runtime.
## Design decisions
- **SQLite WAL mode** — readers don't block writers.
- **Soft delete** — preserves history and foreign-key integrity.
- **Deterministic random** — same text + date always yields same times, so dedup works across restarts.
- **JSON output** — both edit and send scripts emit structured JSON for easy LLM parsing.
- **No YAML** — eliminated race conditions, manual string construction, and fragile parsing.

108
skills/remind/scripts/db.py Normal file
View File

@@ -0,0 +1,108 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""SQLite storage layer for /remind skill.
Schema, connection factory, and audit logging.
"""
from __future__ import annotations
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
SCHEMA = """
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL CHECK(text <> ''),
enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),
timezone TEXT NOT NULL DEFAULT 'Europe/Prague',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS schedule_at (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
at_datetime TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS schedule_cron (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
cron_expr TEXT NOT NULL CHECK(cron_expr <> '')
);
CREATE TABLE IF NOT EXISTS schedule_random (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
times_per_day INTEGER NOT NULL CHECK(times_per_day >= 1),
window_start INTEGER NOT NULL CHECK(window_start >= 0 AND window_start < 1440),
window_end INTEGER NOT NULL CHECK(window_end > 0 AND window_end <= 1440),
days_filter TEXT,
from_date TEXT,
until_date TEXT,
CHECK(window_start < window_end)
);
CREATE TABLE IF NOT EXISTS reminder_fires (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
schedule_id INTEGER NOT NULL,
schedule_type TEXT NOT NULL CHECK(schedule_type IN ('at', 'cron', 'random')),
fire_time TEXT NOT NULL,
delivered_at TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'failed')),
error_message TEXT
);
CREATE INDEX IF NOT EXISTS idx_reminders_text ON reminders(text);
CREATE INDEX IF NOT EXISTS idx_at_datetime ON schedule_at(reminder_id, at_datetime);
CREATE INDEX IF NOT EXISTS idx_cron_expr ON schedule_cron(reminder_id, cron_expr);
CREATE INDEX IF NOT EXISTS idx_random_dates ON schedule_random(from_date, until_date);
CREATE INDEX IF NOT EXISTS idx_fire_lookup ON reminder_fires(
reminder_id, schedule_type, schedule_id, fire_time, status
);
"""
def get_db(path: Path) -> sqlite3.Connection:
"""Return a connection with WAL mode and foreign keys enabled."""
conn = sqlite3.connect(path, isolation_level=None)
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON")
conn.row_factory = sqlite3.Row
return conn
def init_db(path: Path) -> None:
"""Create tables and indexes if they don't exist."""
path.parent.mkdir(parents=True, exist_ok=True)
conn = get_db(path)
try:
conn.executescript(SCHEMA)
finally:
conn.close()
def log_operation(operation: str, reminder_id: int | None, details: str) -> None:
"""Append an audit line to workspace/log/reminder.log."""
workspace = Path(__file__).resolve().parent.parent.parent.parent
log_dir = workspace / "log"
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "reminder.log"
ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
line = f"{ts} [{operation}]"
if reminder_id is not None:
line += f" id={reminder_id}"
if details:
line += f" {details}"
with log_file.open("a", encoding="utf-8") as f:
f.write(line + "\n")

View File

@@ -3,10 +3,10 @@
# requires-python = ">=3.11" # requires-python = ">=3.11"
# dependencies = ["croniter", "pyyaml"] # dependencies = ["croniter", "pyyaml"]
# /// # ///
"""Deterministic CRUD for reminder.yaml. """Deterministic CRUD for reminders backed by SQLite.
CLI tool for LLM skills to create, list, and remove reminders atomically. CLI tool for LLM skills to create, list, edit, enable, disable, and remove reminders.
Never edits reminder.yaml directly — always writes to a .tmp file and renames. All mutations are atomic SQLite transactions with audit logging.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,39 +15,159 @@ import argparse
import json import json
import os import os
import sys import sys
from datetime import date, datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import yaml
from croniter import croniter 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
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent # .../workspace WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
REMINDER_YAML = WORKSPACE / "reminder.yaml" DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite"
DB_PATH = Path(os.environ.get("REMIND_DB", str(DEFAULT_DB_PATH)))
def _load() -> dict: def _now() -> str:
if not REMINDER_YAML.exists(): return datetime.now(timezone.utc).isoformat(timespec="seconds")
return {"reminders": []}
data = yaml.safe_load(REMINDER_YAML.read_text(encoding="utf-8")) or {}
if "reminders" not in data:
data["reminders"] = []
return data
def _save(data: dict) -> None: def _ensure_db() -> None:
tmp = REMINDER_YAML.with_suffix(".yaml.tmp") if not DB_PATH.exists():
tmp.write_text( init_db(DB_PATH)
yaml.safe_dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False),
encoding="utf-8",
def _build_random(args: argparse.Namespace) -> dict | None:
"""Assemble and validate the random schedule block, or None if no --random-* flag given."""
fields = {
"times_per_day": args.random_times_per_day,
"window": args.random_window,
"days": args.random_days,
"from": args.random_from,
"until": args.random_until,
}
if all(value is None for value in fields.values()):
return None
if fields["times_per_day"] is None or fields["window"] is None:
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}
compute_fire_times(__import__("datetime").date(2000, 1, 1), "validation", cfg)
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:
if args.at:
for at_str in args.at:
conn.execute(
"INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)",
(reminder_id, at_str),
) )
os.replace(tmp, REMINDER_YAML) if args.cron:
for expr in args.cron:
conn.execute(
"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"])
conn.execute(
"""
INSERT INTO schedule_random
(reminder_id, times_per_day, window_start, window_end, days_filter, from_date, until_date)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
reminder_id,
random_cfg["times_per_day"],
start,
end,
random_cfg.get("days"),
random_cfg.get("from"),
random_cfg.get("until"),
),
)
def _fetch_reminder(conn, reminder_id: int) -> dict:
row = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at FROM reminders WHERE id = ?",
(reminder_id,),
).fetchone()
if row is None:
raise ValueError(f"reminder {reminder_id} not found")
reminder = dict(row)
reminder["at"] = [
dict(r) for r in conn.execute(
"SELECT id, at_datetime FROM schedule_at WHERE reminder_id = ?", (reminder_id,)
).fetchall()
]
reminder["cron"] = [
dict(r) for r in conn.execute(
"SELECT id, cron_expr FROM schedule_cron WHERE reminder_id = ?", (reminder_id,)
).fetchall()
]
reminder["random"] = [
dict(r) for r in conn.execute(
"SELECT id, times_per_day, window_start, window_end, days_filter, from_date, until_date FROM schedule_random WHERE reminder_id = ?",
(reminder_id,),
).fetchall()
]
return reminder
def _find_by_keyword(conn, keyword: str) -> list[dict]:
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}%",),
).fetchall()
return [dict(r) for r in rows]
def cmd_list(_args: argparse.Namespace) -> int: def cmd_list(_args: argparse.Namespace) -> int:
data = _load() _ensure_db()
print(json.dumps({"reminders": data["reminders"]}, ensure_ascii=False)) 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"
).fetchall()
reminders = []
for row in rows:
reminder = dict(row)
rid = reminder["id"]
reminder["at"] = [
dict(r) for r in conn.execute(
"SELECT id, at_datetime FROM schedule_at WHERE reminder_id = ?", (rid,)
).fetchall()
]
reminder["cron"] = [
dict(r) for r in conn.execute(
"SELECT id, cron_expr FROM schedule_cron WHERE reminder_id = ?", (rid,)
).fetchall()
]
reminder["random"] = [
dict(r) for r in conn.execute(
"SELECT id, times_per_day, window_start, window_end, days_filter, from_date, until_date FROM schedule_random WHERE reminder_id = ?",
(rid,),
).fetchall()
]
reminders.append(reminder)
print(json.dumps({"reminders": reminders}, ensure_ascii=False))
return 0 return 0
finally:
conn.close()
def cmd_add(args: argparse.Namespace) -> int: def cmd_add(args: argparse.Namespace) -> int:
@@ -66,8 +186,6 @@ def cmd_add(args: argparse.Namespace) -> int:
print(json.dumps({"error": "provide --cron, --at, or --random-* options"}), file=sys.stderr) print(json.dumps({"error": "provide --cron, --at, or --random-* options"}), file=sys.stderr)
return 1 return 1
item: dict = {"text": text}
if args.at: if args.at:
for at_str in args.at: for at_str in args.at:
try: try:
@@ -75,45 +193,35 @@ def cmd_add(args: argparse.Namespace) -> int:
except ValueError as exc: except ValueError as exc:
print(json.dumps({"error": f"invalid --at datetime: {exc}"}), file=sys.stderr) print(json.dumps({"error": f"invalid --at datetime: {exc}"}), file=sys.stderr)
return 1 return 1
if len(args.at) == 1:
item["at"] = args.at[0]
else:
item["at_times"] = args.at
if args.cron: if args.cron:
for expr in args.cron: for expr in args.cron:
if not croniter.is_valid(expr): if not croniter.is_valid(expr):
print(json.dumps({"error": f"invalid cron expression: {expr!r}"}), file=sys.stderr) print(json.dumps({"error": f"invalid cron expression: {expr!r}"}), file=sys.stderr)
return 1 return 1
item["cron_exprs"] = args.cron
if random_cfg: _ensure_db()
item["random"] = random_cfg conn = get_db(DB_PATH)
try:
data = _load() conn.execute("BEGIN")
data["reminders"].append(item) now = _now()
_save(data) cur = conn.execute(
print(json.dumps({"added": item}, ensure_ascii=False)) "INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', ?, ?)",
(text, now, now),
)
reminder_id = cur.lastrowid
_insert_schedules(conn, reminder_id, args)
conn.execute("COMMIT")
reminder = _fetch_reminder(conn, reminder_id)
log_operation("ADD", reminder_id, f'text="{text}"')
print(json.dumps({"added": reminder}, ensure_ascii=False))
return 0 return 0
except Exception as exc:
conn.execute("ROLLBACK")
def _build_random(args: argparse.Namespace) -> dict | None: print(json.dumps({"error": str(exc)}), file=sys.stderr)
"""Assemble and validate the random schedule block, or None if no --random-* flag given.""" return 1
fields = { finally:
"times_per_day": args.random_times_per_day, conn.close()
"window": args.random_window,
"days": args.random_days,
"from": args.random_from,
"until": args.random_until,
}
if all(value is None for value in fields.values()):
return None
if fields["times_per_day"] is None or fields["window"] is None:
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}
compute_fire_times(date(2000, 1, 1), "validation", cfg) # raises ValueError on a bad config
return cfg
def cmd_remove(args: argparse.Namespace) -> int: def cmd_remove(args: argparse.Namespace) -> int:
@@ -122,13 +230,13 @@ def cmd_remove(args: argparse.Namespace) -> int:
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr) print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
return 1 return 1
data = _load() _ensure_db()
matches = [r for r in data["reminders"] if keyword in (r.get("text") or "").lower()] conn = get_db(DB_PATH)
try:
matches = _find_by_keyword(conn, keyword)
if len(matches) == 0: if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr) print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1 return 1
if len(matches) > 1: if len(matches) > 1:
print( print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False), json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
@@ -136,36 +244,188 @@ def cmd_remove(args: argparse.Namespace) -> int:
) )
return 1 return 1
removed = matches[0] rid = matches[0]["id"]
data["reminders"] = [r for r in data["reminders"] if r is not removed] conn.execute("BEGIN")
_save(data) conn.execute("UPDATE reminders SET deleted_at = ?, updated_at = ? WHERE id = ?", (_now(), _now(), rid))
print(json.dumps({"removed": removed}, ensure_ascii=False)) conn.execute("COMMIT")
log_operation("REMOVE", rid, f'text="{matches[0]["text"]}"')
reminder = _fetch_reminder(conn, rid)
print(json.dumps({"removed": reminder}, ensure_ascii=False))
return 0 return 0
except Exception as exc:
conn.execute("ROLLBACK")
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
finally:
conn.close()
def cmd_edit(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()
conn = get_db(DB_PATH)
try:
matches = _find_by_keyword(conn, keyword)
if len(matches) == 0:
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
rid = matches[0]["id"]
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
conn.execute("UPDATE reminders SET text = ?, updated_at = ? WHERE id = ?", (new_text, now, rid))
log_operation("EDIT", rid, f'text="{new_text}"')
if args.replace_schedules:
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)
conn.execute("UPDATE reminders SET updated_at = ? WHERE id = ?", (now, rid))
log_operation("EDIT", rid, "schedules replaced")
conn.execute("COMMIT")
reminder = _fetch_reminder(conn, rid)
print(json.dumps({"edited": reminder}, ensure_ascii=False))
return 0
except Exception as exc:
conn.execute("ROLLBACK")
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
finally:
conn.close()
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()
conn = get_db(DB_PATH)
try:
matches = _find_by_keyword(conn, keyword)
if len(matches) == 0:
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
rid = matches[0]["id"]
conn.execute("UPDATE reminders SET enabled = 1, updated_at = ? WHERE id = ?", (_now(), rid))
log_operation("ENABLE", rid, None)
reminder = _fetch_reminder(conn, rid)
print(json.dumps({"enabled": reminder}, ensure_ascii=False))
return 0
except Exception as exc:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
finally:
conn.close()
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()
conn = get_db(DB_PATH)
try:
matches = _find_by_keyword(conn, keyword)
if len(matches) == 0:
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
rid = matches[0]["id"]
conn.execute("UPDATE reminders SET enabled = 0, updated_at = ? WHERE id = ?", (_now(), rid))
log_operation("DISABLE", rid, None)
reminder = _fetch_reminder(conn, rid)
print(json.dumps({"disabled": reminder}, ensure_ascii=False))
return 0
except Exception as exc:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
finally:
conn.close()
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="CRUD for reminder.yaml") 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)
sub.add_parser("list", help="List all reminders as JSON") sub.add_parser("list", help="List all active reminders as JSON")
add_p = sub.add_parser("add", help="Add a new reminder") add_p = sub.add_parser("add", help="Add a new reminder")
add_p.add_argument("--text", required=True, help="Reminder text") add_p.add_argument("--text", required=True, help="Reminder text")
add_p.add_argument("--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)") add_p.add_argument("--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)")
add_p.add_argument("--at", action="append", metavar="ISO_DATETIME", help="One-time datetime ISO 8601 (repeatable, combinable with --cron)") add_p.add_argument("--at", action="append", metavar="ISO_DATETIME", help="One-time datetime ISO 8601 (repeatable)")
add_p.add_argument("--random-times-per-day", type=int, dest="random_times_per_day", metavar="N", help="Random schedule: fires per day") add_p.add_argument("--random-times-per-day", type=int, dest="random_times_per_day", metavar="N", help="Random schedule: fires per day")
add_p.add_argument("--random-window", dest="random_window", metavar="HH:MM-HH:MM", help="Random schedule: daily time window") add_p.add_argument("--random-window", dest="random_window", metavar="HH:MM-HH:MM", help="Random schedule: daily time window")
add_p.add_argument("--random-days", dest="random_days", metavar="DOW", help="Random schedule: cron day-of-week filter, e.g. '1-5' (optional)") add_p.add_argument("--random-days", dest="random_days", metavar="DOW", help="Random schedule: cron day-of-week filter")
add_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD", help="Random schedule: start date, inclusive (optional)") 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, inclusive (optional)") 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") remove_p = sub.add_parser("remove", help="Remove a reminder by keyword (soft delete)")
remove_p.add_argument("--keyword", required=True, help="Substring to match against reminder text") remove_p.add_argument("--keyword", required=True, help="Substring to match against reminder text")
edit_p = sub.add_parser("edit", help="Edit a reminder by keyword")
edit_p.add_argument("--keyword", required=True, help="Substring to match against 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("--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)")
edit_p.add_argument("--at", action="append", metavar="ISO_DATETIME", help="One-time datetime (repeatable)")
edit_p.add_argument("--random-times-per-day", type=int, dest="random_times_per_day", metavar="N")
edit_p.add_argument("--random-window", dest="random_window", metavar="HH:MM-HH:MM")
edit_p.add_argument("--random-days", dest="random_days", metavar="DOW")
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")
enable_p = sub.add_parser("enable", help="Enable a reminder by keyword")
enable_p.add_argument("--keyword", required=True)
disable_p = sub.add_parser("disable", help="Disable a reminder by keyword")
disable_p.add_argument("--keyword", required=True)
args = parser.parse_args() args = parser.parse_args()
dispatch = {"list": cmd_list, "add": cmd_add, "remove": cmd_remove} dispatch = {
sys.exit(dispatch[args.command](args)) "list": cmd_list,
"add": cmd_add,
"remove": cmd_remove,
"edit": cmd_edit,
"enable": cmd_enable,
"disable": cmd_disable,
}
return dispatch[args.command](args)
if __name__ == "__main__": if __name__ == "__main__":
main() sys.exit(main())

View File

@@ -3,41 +3,37 @@
# requires-python = ">=3.11" # requires-python = ">=3.11"
# dependencies = ["croniter", "pyyaml"] # dependencies = ["croniter", "pyyaml"]
# /// # ///
"""Deterministic reminder sender. """Deterministic reminder sender backed by SQLite.
Runs every minute from the nanobot user crontab (NOT through the agent). Runs every minute from the nanobot user crontab.
Reads reminder.yaml, finds reminders due this minute, sends each directly to Reads reminders from SQLite, finds due fires, sends each directly to Telegram,
Telegram via the Bot API, appends the delivery to reminder.log, and dedups via logs delivery to reminder.log, and dedups via reminder_fires table.
.reminder_state.json so each scheduled fire is delivered exactly once.
No LLM and no nanobot process involved on purpose -- see knowledge.md/history
for why the previous agent-driven cron job spammed empty-output messages.
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import os
import sys import sys
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from datetime import datetime from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import yaml import yaml
from croniter import croniter 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
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent # .../workspace WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
REMINDER_YAML = WORKSPACE / "reminder.yaml" DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite"
STATE_FILE = WORKSPACE / ".reminder_state.json" DB_PATH = Path(os.environ.get("REMIND_DB", str(DEFAULT_DB_PATH)))
LOG_DIR = WORKSPACE / "log"
LOG_FILE = LOG_DIR / "reminder.log"
CONFIG = Path.home() / ".nanobot" / "config.json" CONFIG = Path.home() / ".nanobot" / "config.json"
TZ = ZoneInfo("Europe/Prague") TZ = ZoneInfo("Europe/Prague")
CHAT_ID = "8826147089" # Telegram user id (Martin); same target the old cron job used CHAT_ID = "8826147089"
TOLERANCE_SECONDS = 60
def _telegram_token() -> str: def _telegram_token() -> str:
@@ -54,96 +50,165 @@ def _send_telegram(text: str) -> None:
resp.read() resp.read()
def _load_state() -> dict: def _now() -> datetime:
if STATE_FILE.exists(): return datetime.now(TZ).replace(tzinfo=None)
def _due_at(conn, now: datetime) -> list[dict]:
"""Find due one-time reminders."""
since = (now - timedelta(seconds=TOLERANCE_SECONDS)).isoformat(timespec="seconds")
until = now.isoformat(timespec="seconds")
rows = conn.execute(
"""
SELECT r.id, r.text, sa.id AS schedule_id, sa.at_datetime AS fire_time
FROM reminders r
JOIN schedule_at sa ON sa.reminder_id = r.id
WHERE r.enabled = 1 AND r.deleted_at IS NULL
AND sa.at_datetime > ?
AND sa.at_datetime <= ?
AND NOT EXISTS (
SELECT 1 FROM reminder_fires rf
WHERE rf.reminder_id = r.id AND rf.schedule_id = sa.id
AND rf.schedule_type = 'at' AND rf.fire_time = sa.at_datetime
AND rf.status = 'delivered'
)
""",
(since, until),
).fetchall()
return [dict(r) for r in rows]
def _due_cron(conn, now: datetime) -> list[dict]:
"""Find due cron reminders."""
rows = conn.execute(
"""
SELECT r.id, r.text, sc.id AS schedule_id, sc.cron_expr
FROM reminders r
JOIN schedule_cron sc ON sc.reminder_id = r.id
WHERE r.enabled = 1 AND r.deleted_at IS NULL
"""
).fetchall()
due = []
for row in rows:
prev = croniter(row["cron_expr"], now + timedelta(seconds=1)).get_prev(datetime)
if 0 <= (now - prev).total_seconds() < TOLERANCE_SECONDS:
fire_iso = prev.isoformat(timespec="seconds")
already = conn.execute(
"""
SELECT 1 FROM reminder_fires
WHERE reminder_id = ? AND schedule_id = ? AND schedule_type = 'cron'
AND fire_time = ? AND status = 'delivered'
""",
(row["id"], row["schedule_id"], fire_iso),
).fetchone()
if not already:
due.append({
"id": row["id"],
"text": row["text"],
"schedule_id": row["schedule_id"],
"fire_time": fire_iso,
})
return due
def _due_random(conn, now: datetime) -> list[dict]:
"""Find due random reminders."""
rows = conn.execute(
"""
SELECT r.id, r.text, sr.id AS schedule_id, sr.times_per_day, sr.window_start, sr.window_end,
sr.days_filter, sr.from_date, sr.until_date
FROM reminders r
JOIN schedule_random sr ON sr.reminder_id = r.id
WHERE r.enabled = 1 AND r.deleted_at IS NULL
"""
).fetchall()
due = []
for row in rows:
cfg = {
"times_per_day": row["times_per_day"],
"window": f"{_minutes_to_hhmm(row['window_start'])}-{_minutes_to_hhmm(row['window_end'])}",
}
if row["days_filter"]:
cfg["days"] = row["days_filter"]
if row["from_date"]:
cfg["from"] = row["from_date"]
if row["until_date"]:
cfg["until"] = row["until_date"]
try: try:
data = json.loads(STATE_FILE.read_text(encoding="utf-8")) fires = compute_fire_times(now.date(), row["text"], cfg)
return data if isinstance(data, dict) else {} except ValueError as exc:
except Exception: print(f"remind_send: bad random config for {row['text']!r}: {exc}", file=sys.stderr)
return {} continue
return {} for ft in fires:
if 0 <= (now - ft).total_seconds() < TOLERANCE_SECONDS:
fire_iso = ft.isoformat(timespec="seconds")
already = conn.execute(
"""
SELECT 1 FROM reminder_fires
WHERE reminder_id = ? AND schedule_id = ? AND schedule_type = 'random'
AND fire_time = ? AND status = 'delivered'
""",
(row["id"], row["schedule_id"], fire_iso),
).fetchone()
if not already:
due.append({
"id": row["id"],
"text": row["text"],
"schedule_id": row["schedule_id"],
"fire_time": fire_iso,
})
return due
def _key(text: str) -> str: def _minutes_to_hhmm(total: int) -> str:
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:8] return f"{total // 60:02d}:{total % 60:02d}"
def _due_fire(item: dict, now: datetime) -> datetime | None: def _record_fire(conn, reminder_id: int, schedule_id: int, schedule_type: str, fire_time: str, status: str, error: str | None = None) -> None:
"""Most recent scheduled fire-time within the last 60s, or None.""" now = _now().isoformat(timespec="seconds")
fire: datetime | None = None conn.execute(
"""
at_str = item.get("at") INSERT INTO reminder_fires (reminder_id, schedule_id, schedule_type, fire_time, delivered_at, status, error_message)
if at_str: VALUES (?, ?, ?, ?, ?, ?, ?)
at_time = datetime.fromisoformat(at_str).replace(tzinfo=None) """,
if 0 <= (now - at_time).total_seconds() < 60: (reminder_id, schedule_id, schedule_type, fire_time, now if status == "delivered" else None, status, error),
fire = at_time )
for at_str in item.get("at_times", []):
at_time = datetime.fromisoformat(at_str).replace(tzinfo=None)
if 0 <= (now - at_time).total_seconds() < 60 and (fire is None or at_time > fire):
fire = at_time
for expr in item.get("cron_exprs", []):
prev = croniter(expr, now).get_prev(datetime)
if 0 <= (now - prev).total_seconds() < 60 and (fire is None or prev > fire):
fire = prev
random_cfg = item.get("random")
if random_cfg:
try:
for ft in compute_fire_times(now.date(), (item.get("text") or "").strip(), random_cfg):
if 0 <= (now - ft).total_seconds() < 60 and (fire is None or ft > fire):
fire = ft
except ValueError as exc: # malformed config: skip this reminder, keep others working
print(f"remind_send: bad random config for {item.get('text')!r}: {exc}", file=sys.stderr)
return fire
def main() -> None: def main() -> None:
if not REMINDER_YAML.exists(): if not DB_PATH.exists():
return init_db(DB_PATH)
data = yaml.safe_load(REMINDER_YAML.read_text(encoding="utf-8")) or {} conn = get_db(DB_PATH)
now = datetime.now(TZ).replace(tzinfo=None) try:
now = _now()
due = _due_at(conn, now) + _due_cron(conn, now) + _due_random(conn, now)
state = _load_state() for fire in due:
fresh: dict[str, str] = {} text = fire["text"]
rid = fire["id"]
for item in data.get("reminders", []): sid = fire["schedule_id"]
text = (item.get("text") or "").strip() ft = fire["fire_time"]
if not text: # Determine schedule_type from which query produced it
continue # We can infer: if 'schedule_id' came from schedule_at, it's 'at'
key = _key(text) # But we don't have that info here. Let's look it up.
last = state.get(key) 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 = ?",
fire = _due_fire(item, now) (sid, sid, sid),
if fire is None: ).fetchone()
if last: # preserve dedup info for reminders not due this minute schedule_type = st[0] if st else "unknown"
fresh[key] = last
continue
fire_iso = fire.isoformat()
if last == fire_iso: # this exact fire was already delivered
fresh[key] = last
continue
try: try:
_send_telegram(f"⏰ Reminder: {text}") _send_telegram(f"⏰ Reminder: {text}")
except Exception as e: # leave state untouched so next run retries 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)
if last: _record_fire(conn, rid, sid, schedule_type, ft, "failed", str(e))
fresh[key] = last
continue continue
ts = datetime.now(TZ).replace(tzinfo=None).isoformat(timespec="seconds") _record_fire(conn, rid, sid, schedule_type, ft, "delivered")
LOG_DIR.mkdir(parents=True, exist_ok=True) log_operation("DELIVER", rid, f'text="{text}"')
with LOG_FILE.open("a", encoding="utf-8") as f: finally:
f.write(f"{ts} {text}\n") conn.close()
fresh[key] = fire_iso
if fresh != state:
STATE_FILE.write_text(json.dumps(fresh, indent=2, ensure_ascii=False), encoding="utf-8")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -0,0 +1,124 @@
import sqlite3
from pathlib import Path
import pytest
from db import get_db, init_db
def test_init_db_creates_tables(tmp_path):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
conn = get_db(db_path)
try:
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).fetchall()
names = [r["name"] for r in tables]
assert "reminders" in names
assert "schedule_at" in names
assert "schedule_cron" in names
assert "schedule_random" in names
assert "reminder_fires" in names
finally:
conn.close()
def test_foreign_keys_enforced(tmp_path):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
conn = get_db(db_path)
try:
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (999, '2026-06-10T10:00:00')"
)
finally:
conn.close()
def test_reminder_constraints(tmp_path):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
conn = get_db(db_path)
try:
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES ('', 1, 'UTC', 'now', 'now')"
)
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES ('x', 2, 'UTC', 'now', 'now')"
)
finally:
conn.close()
def test_schedule_random_window_constraint(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 ('x', 1, 'UTC', 'now', 'now')"
)
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO schedule_random (reminder_id, times_per_day, window_start, window_end) VALUES (?, 1, 1200, 600)",
(rid,),
)
finally:
conn.close()
def test_reminder_crud(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', '2026-06-10T10:00:00', '2026-06-10T10:00:00')",
("test reminder",),
)
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)",
(rid, "2026-06-11T09:00:00"),
)
conn.execute(
"INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)",
(rid, "0 9 * * *"),
)
conn.execute(
"INSERT INTO schedule_random (reminder_id, times_per_day, window_start, window_end) VALUES (?, ?, ?, ?)",
(rid, 2, 540, 1260),
)
row = conn.execute("SELECT * FROM reminders WHERE id = ?", (rid,)).fetchone()
assert row["text"] == "test reminder"
at_rows = conn.execute("SELECT * FROM schedule_at WHERE reminder_id = ?", (rid,)).fetchall()
assert len(at_rows) == 1
assert at_rows[0]["at_datetime"] == "2026-06-11T09:00:00"
cron_rows = conn.execute("SELECT * FROM schedule_cron WHERE reminder_id = ?", (rid,)).fetchall()
assert len(cron_rows) == 1
assert cron_rows[0]["cron_expr"] == "0 9 * * *"
random_rows = conn.execute("SELECT * FROM schedule_random WHERE reminder_id = ?", (rid,)).fetchall()
assert len(random_rows) == 1
assert random_rows[0]["times_per_day"] == 2
# soft delete
conn.execute("UPDATE reminders SET deleted_at = '2026-06-10T11:00:00' WHERE id = ?", (rid,))
row = conn.execute("SELECT deleted_at FROM reminders WHERE id = ?", (rid,)).fetchone()
assert row["deleted_at"] is not None
# cascade delete
conn.execute("DELETE FROM reminders WHERE id = ?", (rid,))
assert conn.execute("SELECT 1 FROM schedule_at WHERE reminder_id = ?", (rid,)).fetchone() is None
assert conn.execute("SELECT 1 FROM schedule_cron WHERE reminder_id = ?", (rid,)).fetchone() is None
assert conn.execute("SELECT 1 FROM schedule_random WHERE reminder_id = ?", (rid,)).fetchone() is None
finally:
conn.close()

View File

@@ -0,0 +1,184 @@
import json
import os
import sqlite3
import sys
from pathlib import Path
import pytest
SCRIPTS = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS))
from db import get_db, init_db
import remind_edit
def _run(db_path, argv):
"""Run remind_edit main with a temporary DB path."""
original_db_path = remind_edit.DB_PATH
try:
remind_edit.DB_PATH = db_path
# Patch the module-level DB_PATH used by functions
sys.argv = ["remind_edit.py"] + argv
return remind_edit.main()
finally:
remind_edit.DB_PATH = original_db_path
def test_add_list(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
ret = _run(db_path, ["add", "--text", "drink water", "--cron", "0 9 * * *"])
captured = capsys.readouterr()
assert ret == 0
data = json.loads(captured.out)
assert data["added"]["text"] == "drink water"
assert len(data["added"]["cron"]) == 1
ret = _run(db_path, ["list"])
captured = capsys.readouterr()
assert ret == 0
data = json.loads(captured.out)
assert len(data["reminders"]) == 1
assert data["reminders"][0]["text"] == "drink water"
def test_add_at_validation(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
ret = _run(db_path, ["add", "--text", "x", "--at", "not-a-date"])
captured = capsys.readouterr()
assert ret == 1
assert "invalid --at datetime" in captured.err
def test_add_cron_validation(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
ret = _run(db_path, ["add", "--text", "x", "--cron", "bad"])
captured = capsys.readouterr()
assert ret == 1
assert "invalid cron expression" in captured.err
def test_remove(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
_run(db_path, ["add", "--text", "stretch", "--cron", "0 10 * * *"])
capsys.readouterr() # clear setup output
ret = _run(db_path, ["remove", "--keyword", "stretch"])
captured = capsys.readouterr()
assert ret == 0
data = json.loads(captured.out)
assert data["removed"]["text"] == "stretch"
ret = _run(db_path, ["list"])
captured = capsys.readouterr()
data = json.loads(captured.out)
assert len(data["reminders"]) == 0
def test_remove_no_match(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
ret = _run(db_path, ["remove", "--keyword", "nonexistent"])
captured = capsys.readouterr()
assert ret == 1
assert "no match" in captured.err
def test_remove_ambiguous(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 tea", "--cron", "0 10 * * *"])
ret = _run(db_path, ["remove", "--keyword", "drink"])
captured = capsys.readouterr()
assert ret == 1
assert "ambiguous" in captured.err
def test_edit_text(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
_run(db_path, ["add", "--text", "old text", "--cron", "0 9 * * *"])
capsys.readouterr() # clear setup output
ret = _run(db_path, ["edit", "--keyword", "old", "--text", "new text"])
captured = capsys.readouterr()
assert ret == 0
data = json.loads(captured.out)
assert data["edited"]["text"] == "new text"
def test_edit_replace_schedules(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
_run(db_path, ["add", "--text", "task", "--cron", "0 9 * * *"])
capsys.readouterr() # clear setup output
ret = _run(db_path, ["edit", "--keyword", "task", "--replace-schedules", "--at", "2026-06-15T10:00:00"])
captured = capsys.readouterr()
assert ret == 0
data = json.loads(captured.out)
assert len(data["edited"]["cron"]) == 0
assert len(data["edited"]["at"]) == 1
def test_enable_disable(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
_run(db_path, ["add", "--text", "toggle me", "--cron", "0 9 * * *"])
capsys.readouterr() # clear setup output
ret = _run(db_path, ["disable", "--keyword", "toggle"])
captured = capsys.readouterr()
assert ret == 0
data = json.loads(captured.out)
assert data["disabled"]["enabled"] == 0
ret = _run(db_path, ["enable", "--keyword", "toggle"])
captured = capsys.readouterr()
assert ret == 0
data = json.loads(captured.out)
assert data["enabled"]["enabled"] == 1
def test_add_combined_schedules(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
ret = _run(db_path, [
"add", "--text", "multi",
"--at", "2026-06-15T10:00:00",
"--cron", "0 9 * * *",
"--random-times-per-day", "2",
"--random-window", "08:00-20:00",
])
captured = capsys.readouterr()
assert ret == 0
data = json.loads(captured.out)
assert len(data["added"]["at"]) == 1
assert len(data["added"]["cron"]) == 1
assert len(data["added"]["random"]) == 1
def test_add_random_validation(tmp_path, capsys):
db_path = tmp_path / "test.sqlite"
init_db(db_path)
ret = _run(db_path, [
"add", "--text", "bad",
"--random-times-per-day", "2",
"--random-window", "08:00-08:01",
])
captured = capsys.readouterr()
assert ret == 1
assert "window" in captured.err.lower() or "gap" in captured.err.lower()

View File

@@ -0,0 +1,198 @@
import json
import os
import sqlite3
import sys
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
SCRIPTS = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS))
from db import get_db, init_db
import remind_send
def _run_send(db_path, now=None):
"""Run remind_send main with a temporary DB path and optional mocked now."""
original_db_path = remind_send.DB_PATH
try:
remind_send.DB_PATH = db_path
if now is not None:
remind_send._now = 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)
def test_due_at_delivers_once(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 (?, 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),
)
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")
# second run — dedup
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_due_cron_delivers_once(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 (?, 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 * * *"),
)
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")
# second run — dedup
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_due_random_delivers_once(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 (?, 1, 'Europe/Prague', 'now', 'now')",
("random reminder",),
)
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO schedule_random (reminder_id, times_per_day, window_start, window_end) VALUES (?, ?, ?, ?)",
(rid, 2, 540, 1260),
)
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"})
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")
# all deduped now
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_not_called()
def test_disabled_not_sent(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 (?, 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"),
)
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):
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"),
)
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_delivery_failure_logged(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 (?, 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"),
)
finally:
conn.close()
now = datetime(2026, 6, 10, 10, 0, 0)
with patch.object(remind_send, "_send_telegram", side_effect=RuntimeError("network down")) as mock_send:
_run_send(db_path, now)
mock_send.assert_called_once()
conn = get_db(db_path)
try:
row = conn.execute(
"SELECT status, error_message FROM reminder_fires WHERE reminder_id = ?", (rid,)
).fetchone()
assert row["status"] == "failed"
assert "network down" in row["error_message"]
finally:
conn.close()