Files
nanobot-runtime/skills/remind/scripts/store.py
2026-06-24 08:11:12 +02:00

328 lines
11 KiB
Python

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""Data-access layer for the /remind skill.
Pure SQL + lifecycle helpers. No printing, no argparse, no sys.exit.
"""
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
from db import get_db, init_db
from random_times import parse_window
@contextmanager
def connection(db_path: Path) -> Iterator[sqlite3.Connection]:
"""Open a connection, initialising the DB if missing."""
if not db_path.exists():
init_db(db_path)
conn = get_db(db_path)
try:
yield conn
finally:
conn.close()
@contextmanager
def transaction(db_path: Path) -> Iterator[sqlite3.Connection]:
"""Open a connection wrapped in an explicit transaction."""
with connection(db_path) as conn:
conn.execute("BEGIN")
try:
yield conn
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
# ---------------------------------------------------------------------------
# Write helpers
# ---------------------------------------------------------------------------
def insert_reminder(conn: sqlite3.Connection, text: str, now: str) -> int:
"""Insert a new reminder and return its id."""
cur = conn.execute(
"INSERT INTO reminders (text, enabled, timezone, created_at, updated_at) VALUES (?, 1, 'Europe/Prague', ?, ?)",
(text, now, now),
)
return cur.lastrowid
def insert_schedules(
conn: sqlite3.Connection,
reminder_id: int,
at_list: list[str] | None,
cron_list: list[str] | None,
random_cfg: dict | None,
) -> None:
"""Insert schedule rows for a reminder."""
if at_list:
for at_str in at_list:
conn.execute(
"INSERT INTO schedule_at (reminder_id, at_datetime) VALUES (?, ?)",
(reminder_id, at_str),
)
if cron_list:
for expr in cron_list:
conn.execute(
"INSERT INTO schedule_cron (reminder_id, cron_expr) VALUES (?, ?)",
(reminder_id, expr),
)
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, period)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
reminder_id,
random_cfg["times_per_day"],
start,
end,
random_cfg.get("days"),
random_cfg.get("from"),
random_cfg.get("until"),
random_cfg.get("period", "day"),
),
)
def soft_delete(conn: sqlite3.Connection, rid: int, now: str) -> None:
conn.execute("UPDATE reminders SET deleted_at = ?, updated_at = ? WHERE id = ?", (now, now, rid))
def update_text(conn: sqlite3.Connection, rid: int, text: str, now: str) -> None:
conn.execute("UPDATE reminders SET text = ?, updated_at = ? WHERE id = ?", (text, now, rid))
def delete_schedules(conn: sqlite3.Connection, rid: int) -> None:
"""Delete all schedule rows for a reminder across all three schedule tables."""
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,))
def touch(conn: sqlite3.Connection, rid: int, now: str) -> None:
conn.execute("UPDATE reminders SET updated_at = ? WHERE id = ?", (now, rid))
def set_enabled(conn: sqlite3.Connection, rid: int, enabled: bool, now: str) -> None:
conn.execute("UPDATE reminders SET enabled = ?, updated_at = ? WHERE id = ?", (int(enabled), now, rid))
# ---------------------------------------------------------------------------
# Read helpers
# ---------------------------------------------------------------------------
def fetch_reminder(conn: sqlite3.Connection, reminder_id: int) -> dict:
"""Fetch a reminder with nested at/cron/random schedule lists."""
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, period "
"FROM schedule_random WHERE reminder_id = ?",
(reminder_id,),
).fetchall()
]
return reminder
def schedules_for(conn: sqlite3.Connection, reminder_id: int) -> dict:
"""Return raw schedule rows grouped by type; formatting stays in the CLI."""
return {
"at": [
dict(r) for r in conn.execute(
"SELECT at_datetime FROM schedule_at WHERE reminder_id = ?", (reminder_id,)
).fetchall()
],
"cron": [
dict(r) for r in conn.execute(
"SELECT cron_expr FROM schedule_cron WHERE reminder_id = ?", (reminder_id,)
).fetchall()
],
"random": [
dict(r) for r in conn.execute(
"SELECT times_per_day, window_start, window_end, days_filter, from_date, until_date, period "
"FROM schedule_random WHERE reminder_id = ?",
(reminder_id,),
).fetchall()
],
}
def list_active(conn: sqlite3.Connection) -> list[dict]:
rows = conn.execute(
"SELECT id, text, enabled FROM reminders WHERE deleted_at IS NULL ORDER BY id"
).fetchall()
return [dict(r) for r in rows]
def find_active_by_id(conn: sqlite3.Connection, rid: int) -> dict | None:
"""Return the reminder row for an internal DB id, or None if not found/deleted."""
row = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at "
"FROM reminders WHERE id = ? AND deleted_at IS NULL",
(rid,),
).fetchone()
return dict(row) if row else None
def find_active_by_keyword(conn: sqlite3.Connection, keyword: str) -> list[dict]:
escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
rows = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at "
"FROM reminders WHERE text LIKE ? ESCAPE '\\' AND deleted_at IS NULL",
(f"%{escaped}%",),
).fetchall()
return [dict(r) for r in rows]
def active_display_order(conn: sqlite3.Connection) -> list[int]:
"""Internal ids of active reminders in display order (ascending by id)."""
rows = conn.execute(
"SELECT id FROM reminders WHERE deleted_at IS NULL ORDER BY id"
).fetchall()
return [row["id"] for row in rows]
def delivered_since(conn: sqlite3.Connection, since: str) -> list[dict]:
rows = conn.execute(
"""
SELECT f.delivered_at, r.text
FROM reminder_fires f
JOIN reminders r ON r.id = f.reminder_id
WHERE f.status = 'delivered' AND f.fire_time >= ?
ORDER BY f.delivered_at
""",
(since,),
).fetchall()
return [dict(r) for r in rows]
def delivered_today(conn: sqlite3.Connection, today: str) -> list[dict]:
rows = conn.execute(
"""
SELECT f.delivered_at, r.text
FROM reminder_fires f
JOIN reminders r ON r.id = f.reminder_id
WHERE f.status = 'delivered' AND substr(f.fire_time, 1, 10) = ?
ORDER BY f.delivered_at
""",
(today,),
).fetchall()
return [dict(r) for r in rows]
# ---------------------------------------------------------------------------
# Sender helpers (reminder_fires)
# ---------------------------------------------------------------------------
def due_at(conn: sqlite3.Connection, since: str, until: str) -> list[dict]:
"""One-time reminders firing in (since, until] that were not yet delivered."""
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 enabled_cron(conn: sqlite3.Connection) -> list[dict]:
"""All cron schedules on active, enabled reminders (due-check happens in the sender)."""
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()
return [dict(r) for r in rows]
def enabled_random(conn: sqlite3.Connection) -> list[dict]:
"""All random schedules on active, enabled reminders (fire times computed in the sender)."""
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, sr.period
FROM reminders r
JOIN schedule_random sr ON sr.reminder_id = r.id
WHERE r.enabled = 1 AND r.deleted_at IS NULL
"""
).fetchall()
return [dict(r) for r in rows]
def is_fire_delivered(
conn: sqlite3.Connection, reminder_id: int, schedule_id: int, schedule_type: str, fire_time: str
) -> bool:
"""Whether this exact fire was already delivered (dedup guard)."""
row = conn.execute(
"""
SELECT 1 FROM reminder_fires
WHERE reminder_id = ? AND schedule_id = ? AND schedule_type = ?
AND fire_time = ? AND status = 'delivered'
""",
(reminder_id, schedule_id, schedule_type, fire_time),
).fetchone()
return row is not None
def record_fire(
conn: sqlite3.Connection,
reminder_id: int,
schedule_id: int,
schedule_type: str,
fire_time: str,
status: str,
delivered_at: str | None = None,
error: str | None = None,
) -> None:
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, delivered_at, status, error),
)