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

142 lines
5.1 KiB
Python

#!/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()