From b2d367e6a47cc99dbc7f3af07dcdd24bf7967fe9 Mon Sep 17 00:00:00 2001 From: lachtan Date: Wed, 10 Jun 2026 07:10:11 +0200 Subject: [PATCH] migrace /remind na sqlite --- scripts/migrate_yaml_to_sqlite.py | 124 +++++++ skills/remind/SKILL.md | 131 +++++--- skills/remind/scripts/db.py | 108 ++++++ skills/remind/scripts/remind_edit.py | 424 +++++++++++++++++++----- skills/remind/scripts/remind_send.py | 253 ++++++++------ skills/remind/tests/test_db.py | 124 +++++++ skills/remind/tests/test_remind_edit.py | 184 ++++++++++ skills/remind/tests/test_remind_send.py | 198 +++++++++++ 8 files changed, 1315 insertions(+), 231 deletions(-) create mode 100644 scripts/migrate_yaml_to_sqlite.py create mode 100644 skills/remind/scripts/db.py create mode 100644 skills/remind/tests/test_db.py create mode 100644 skills/remind/tests/test_remind_edit.py create mode 100644 skills/remind/tests/test_remind_send.py diff --git a/scripts/migrate_yaml_to_sqlite.py b/scripts/migrate_yaml_to_sqlite.py new file mode 100644 index 0000000..102eb39 --- /dev/null +++ b/scripts/migrate_yaml_to_sqlite.py @@ -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()) diff --git a/skills/remind/SKILL.md b/skills/remind/SKILL.md index 2c36c52..19a3445 100644 --- a/skills/remind/SKILL.md +++ b/skills/remind/SKILL.md @@ -1,71 +1,92 @@ ---- -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 -# 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 ` +### 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": [...]}`. -- **`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. +The LLM parses natural language and calls `remind_edit.py add` with the appropriate flags: -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: - - 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). +``` +/remind list +``` -## 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. -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:00–21:00, Mon–Fri" (include `days`/`from`/`until` only if present). -4. If `reminders` is empty, say so. +### Edit a reminder -## 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 "..."`. -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. +Calls `remind_edit.py edit --keyword `. Keyword is matched case-insensitively against reminder text. Ambiguous matches are rejected. -## 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`. -- **Read via the `list` subcommand** — never read the YAML file directly; always `remind_edit.py list`. -- 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. -- Timezone is always `Europe/Prague` unless the user explicitly requests otherwise. +``` +/remind disable keyword +/remind enable keyword +``` + +### Remove a reminder + +``` +/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. diff --git a/skills/remind/scripts/db.py b/skills/remind/scripts/db.py new file mode 100644 index 0000000..d9be4a8 --- /dev/null +++ b/skills/remind/scripts/db.py @@ -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") diff --git a/skills/remind/scripts/remind_edit.py b/skills/remind/scripts/remind_edit.py index a636dc0..ee44152 100755 --- a/skills/remind/scripts/remind_edit.py +++ b/skills/remind/scripts/remind_edit.py @@ -3,10 +3,10 @@ # requires-python = ">=3.11" # 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. -Never edits reminder.yaml directly — always writes to a .tmp file and renames. +CLI tool for LLM skills to create, list, edit, enable, disable, and remove reminders. +All mutations are atomic SQLite transactions with audit logging. """ from __future__ import annotations @@ -15,39 +15,159 @@ import argparse import json import os import sys -from datetime import date, datetime +from datetime import datetime, timezone from pathlib import Path -import yaml from croniter import croniter +from db import get_db, init_db, log_operation from random_times import compute_fire_times -WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent # .../workspace -REMINDER_YAML = WORKSPACE / "reminder.yaml" +WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent +DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite" +DB_PATH = Path(os.environ.get("REMIND_DB", str(DEFAULT_DB_PATH))) -def _load() -> dict: - if not REMINDER_YAML.exists(): - return {"reminders": []} - data = yaml.safe_load(REMINDER_YAML.read_text(encoding="utf-8")) or {} - if "reminders" not in data: - data["reminders"] = [] - return data +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") -def _save(data: dict) -> None: - tmp = REMINDER_YAML.with_suffix(".yaml.tmp") - tmp.write_text( - yaml.safe_dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False), - encoding="utf-8", - ) - os.replace(tmp, REMINDER_YAML) +def _ensure_db() -> None: + if not DB_PATH.exists(): + init_db(DB_PATH) + + +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), + ) + 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: - data = _load() - print(json.dumps({"reminders": data["reminders"]}, ensure_ascii=False)) - return 0 + _ensure_db() + 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 + finally: + conn.close() 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) return 1 - item: dict = {"text": text} - if args.at: for at_str in args.at: try: @@ -75,45 +193,35 @@ def cmd_add(args: argparse.Namespace) -> int: except ValueError as exc: print(json.dumps({"error": f"invalid --at datetime: {exc}"}), file=sys.stderr) return 1 - if len(args.at) == 1: - item["at"] = args.at[0] - else: - item["at_times"] = args.at if args.cron: for expr in args.cron: if not croniter.is_valid(expr): print(json.dumps({"error": f"invalid cron expression: {expr!r}"}), file=sys.stderr) return 1 - item["cron_exprs"] = args.cron - if random_cfg: - item["random"] = random_cfg - - data = _load() - data["reminders"].append(item) - _save(data) - print(json.dumps({"added": item}, ensure_ascii=False)) - return 0 - - -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(date(2000, 1, 1), "validation", cfg) # raises ValueError on a bad config - return cfg + _ensure_db() + conn = get_db(DB_PATH) + try: + conn.execute("BEGIN") + now = _now() + cur = conn.execute( + "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 + except Exception as exc: + conn.execute("ROLLBACK") + print(json.dumps({"error": str(exc)}), file=sys.stderr) + return 1 + finally: + conn.close() def cmd_remove(args: argparse.Namespace) -> int: @@ -122,50 +230,202 @@ def cmd_remove(args: argparse.Namespace) -> int: print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr) return 1 - data = _load() - matches = [r for r in data["reminders"] if keyword in (r.get("text") or "").lower()] + _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 - if len(matches) == 0: - print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr) + rid = matches[0]["id"] + conn.execute("BEGIN") + conn.execute("UPDATE reminders SET deleted_at = ?, updated_at = ? WHERE id = ?", (_now(), _now(), rid)) + 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 + 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 - if len(matches) > 1: - print( - json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False), - file=sys.stderr, - ) + _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 - removed = matches[0] - data["reminders"] = [r for r in data["reminders"] if r is not removed] - _save(data) - print(json.dumps({"removed": removed}, ensure_ascii=False)) - return 0 + _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: - 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.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.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("--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-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-from", dest="random_from", metavar="YYYY-MM-DD", help="Random schedule: start date, inclusive (optional)") - 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-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") + 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") + 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() - dispatch = {"list": cmd_list, "add": cmd_add, "remove": cmd_remove} - sys.exit(dispatch[args.command](args)) + dispatch = { + "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__": - main() + sys.exit(main()) diff --git a/skills/remind/scripts/remind_send.py b/skills/remind/scripts/remind_send.py index 4ab1b86..5291bb3 100644 --- a/skills/remind/scripts/remind_send.py +++ b/skills/remind/scripts/remind_send.py @@ -3,41 +3,37 @@ # requires-python = ">=3.11" # dependencies = ["croniter", "pyyaml"] # /// -"""Deterministic reminder sender. +"""Deterministic reminder sender backed by SQLite. -Runs every minute from the nanobot user crontab (NOT through the agent). -Reads reminder.yaml, finds reminders due this minute, sends each directly to -Telegram via the Bot API, appends the delivery to reminder.log, and dedups via -.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. +Runs every minute from the nanobot user crontab. +Reads reminders from SQLite, finds due fires, sends each directly to Telegram, +logs delivery to reminder.log, and dedups via reminder_fires table. """ from __future__ import annotations -import hashlib import json +import os import sys import urllib.parse import urllib.request -from datetime import datetime +from datetime import date, datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo import yaml from croniter import croniter +from db import get_db, init_db, log_operation from random_times import compute_fire_times -WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent # .../workspace -REMINDER_YAML = WORKSPACE / "reminder.yaml" -STATE_FILE = WORKSPACE / ".reminder_state.json" -LOG_DIR = WORKSPACE / "log" -LOG_FILE = LOG_DIR / "reminder.log" +WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent +DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite" +DB_PATH = Path(os.environ.get("REMIND_DB", str(DEFAULT_DB_PATH))) CONFIG = Path.home() / ".nanobot" / "config.json" 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: @@ -54,96 +50,165 @@ def _send_telegram(text: str) -> None: resp.read() -def _load_state() -> dict: - if STATE_FILE.exists(): +def _now() -> datetime: + 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: - data = json.loads(STATE_FILE.read_text(encoding="utf-8")) - return data if isinstance(data, dict) else {} - except Exception: - return {} - return {} + fires = compute_fire_times(now.date(), row["text"], cfg) + except ValueError as exc: + print(f"remind_send: bad random config for {row['text']!r}: {exc}", file=sys.stderr) + continue + 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: - return hashlib.sha1(text.encode("utf-8")).hexdigest()[:8] +def _minutes_to_hhmm(total: int) -> str: + return f"{total // 60:02d}:{total % 60:02d}" -def _due_fire(item: dict, now: datetime) -> datetime | None: - """Most recent scheduled fire-time within the last 60s, or None.""" - fire: datetime | None = None - - at_str = item.get("at") - if at_str: - at_time = datetime.fromisoformat(at_str).replace(tzinfo=None) - if 0 <= (now - at_time).total_seconds() < 60: - 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 _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") + conn.execute( + """ + INSERT INTO reminder_fires (reminder_id, schedule_id, schedule_type, fire_time, delivered_at, status, error_message) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (reminder_id, schedule_id, schedule_type, fire_time, now if status == "delivered" else None, status, error), + ) def main() -> None: - if not REMINDER_YAML.exists(): - return + if not DB_PATH.exists(): + init_db(DB_PATH) - data = yaml.safe_load(REMINDER_YAML.read_text(encoding="utf-8")) or {} - now = datetime.now(TZ).replace(tzinfo=None) + conn = get_db(DB_PATH) + try: + now = _now() + due = _due_at(conn, now) + _due_cron(conn, now) + _due_random(conn, now) - state = _load_state() - fresh: dict[str, str] = {} + for fire in due: + text = fire["text"] + rid = fire["id"] + sid = fire["schedule_id"] + ft = fire["fire_time"] + # Determine schedule_type from which query produced it + # 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" - for item in data.get("reminders", []): - text = (item.get("text") or "").strip() - if not text: - continue - key = _key(text) - last = state.get(key) + try: + _send_telegram(f"⏰ Reminder: {text}") + 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)) + continue - fire = _due_fire(item, now) - if fire is None: - if last: # preserve dedup info for reminders not due this minute - fresh[key] = last - continue - - fire_iso = fire.isoformat() - if last == fire_iso: # this exact fire was already delivered - fresh[key] = last - continue - - try: - _send_telegram(f"⏰ Reminder: {text}") - except Exception as e: # leave state untouched so next run retries - print(f"remind_send: delivery failed for {text!r}: {e}", file=sys.stderr) - if last: - fresh[key] = last - continue - - ts = datetime.now(TZ).replace(tzinfo=None).isoformat(timespec="seconds") - LOG_DIR.mkdir(parents=True, exist_ok=True) - with LOG_FILE.open("a", encoding="utf-8") as f: - f.write(f"{ts} {text}\n") - fresh[key] = fire_iso - - if fresh != state: - STATE_FILE.write_text(json.dumps(fresh, indent=2, ensure_ascii=False), encoding="utf-8") + _record_fire(conn, rid, sid, schedule_type, ft, "delivered") + log_operation("DELIVER", rid, f'text="{text}"') + finally: + conn.close() if __name__ == "__main__": diff --git a/skills/remind/tests/test_db.py b/skills/remind/tests/test_db.py new file mode 100644 index 0000000..1e44053 --- /dev/null +++ b/skills/remind/tests/test_db.py @@ -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() diff --git a/skills/remind/tests/test_remind_edit.py b/skills/remind/tests/test_remind_edit.py new file mode 100644 index 0000000..83e1202 --- /dev/null +++ b/skills/remind/tests/test_remind_edit.py @@ -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() diff --git a/skills/remind/tests/test_remind_send.py b/skills/remind/tests/test_remind_send.py new file mode 100644 index 0000000..88e82db --- /dev/null +++ b/skills/remind/tests/test_remind_send.py @@ -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()