#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.11" # dependencies = ["croniter"] # /// """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 from croniter import croniter from db import log_operation from random_times import compute_fire_times, random_cfg_from_row import store 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") TOLERANCE_SECONDS = 60 FALLBACK_CHAT_ID = "8826147089" def _telegram_config() -> tuple[str, str]: """Return (bot token, chat id). Chat id reads channels.telegram.allowFrom[0], with a constant fallback.""" data = json.loads(CONFIG.read_text(encoding="utf-8")) telegram = data["channels"]["telegram"] allow_from = telegram.get("allowFrom") or [] chat_id = str(allow_from[0]) if allow_from else FALLBACK_CHAT_ID return telegram["token"], chat_id def _send_telegram(text: str, token: str, chat_id: str) -> None: 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_prague() -> 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") return [{**row, "schedule_type": "at"} for row in store.due_at(conn, since, until)] def _due_cron(conn, now: datetime) -> list[dict]: """Find due cron reminders.""" due = [] for row in store.enabled_cron(conn): 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") if not store.is_fire_delivered(conn, row["id"], row["schedule_id"], "cron", fire_iso): due.append({ "id": row["id"], "text": row["text"], "schedule_id": row["schedule_id"], "fire_time": fire_iso, "schedule_type": "cron", }) return due def _due_random(conn, now: datetime) -> list[dict]: """Find due random reminders.""" due = [] for row in store.enabled_random(conn): cfg = random_cfg_from_row(row) 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") if not store.is_fire_delivered(conn, row["id"], row["schedule_id"], "random", fire_iso): due.append({ "id": row["id"], "text": row["text"], "schedule_id": row["schedule_id"], "fire_time": fire_iso, "schedule_type": "random", }) return due def _record_fire(conn, reminder_id: int, schedule_id: int, schedule_type: str, fire_time: str, status: str, error: str | None = None) -> None: delivered_at = _now_prague().isoformat(timespec="seconds") if status == "delivered" else None store.record_fire(conn, reminder_id, schedule_id, schedule_type, fire_time, status, delivered_at, error) def main() -> None: with store.connection(DB_PATH) as conn: now = _now_prague() due = _due_at(conn, now) + _due_cron(conn, now) + _due_random(conn, now) if not due: return token, chat_id = _telegram_config() for fire in due: text = fire["text"] rid = fire["id"] sid = fire["schedule_id"] ft = fire["fire_time"] schedule_type = fire["schedule_type"] try: _send_telegram(f"⏰ Reminder: {text}", token, chat_id) 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}"') if __name__ == "__main__": main()