#!/usr/bin/env python3 # /// script # requires-python = ">=3.11" # dependencies = ["croniter", "pyyaml"] # /// """Deterministic reminder sender backed by SQLite. 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 json import os import sys import urllib.parse import urllib.request 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 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" TOLERANCE_SECONDS = 60 def _telegram_token() -> str: data = json.loads(CONFIG.read_text(encoding="utf-8")) return data["channels"]["telegram"]["token"] def _send_telegram(text: str) -> None: token = _telegram_token() url = f"https://api.telegram.org/bot{token}/sendMessage" payload = urllib.parse.urlencode({"chat_id": CHAT_ID, "text": text}).encode() req = urllib.request.Request(url, data=payload, method="POST") with urllib.request.urlopen(req, timeout=15) as resp: resp.read() 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: 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 _minutes_to_hhmm(total: int) -> str: return f"{total // 60:02d}:{total % 60:02d}" 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 DB_PATH.exists(): init_db(DB_PATH) conn = get_db(DB_PATH) try: now = _now() due = _due_at(conn, now) + _due_cron(conn, now) + _due_random(conn, now) 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" 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 _record_fire(conn, rid, sid, schedule_type, ft, "delivered") log_operation("DELIVER", rid, f'text="{text}"') finally: conn.close() if __name__ == "__main__": main()