Files
nanobot-runtime/skills/remind/scripts/remind_send.py
2026-06-10 06:39:52 +02:00

151 lines
4.9 KiB
Python

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["croniter", "pyyaml"]
# ///
"""Deterministic reminder sender.
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.
"""
from __future__ import annotations
import hashlib
import json
import sys
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import yaml
from croniter import croniter
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"
CONFIG = Path.home() / ".nanobot" / "config.json"
TZ = ZoneInfo("Europe/Prague")
CHAT_ID = "8826147089" # Telegram user id (Martin); same target the old cron job used
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 _load_state() -> dict:
if STATE_FILE.exists():
try:
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
return {}
def _key(text: str) -> str:
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:8]
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 main() -> None:
if not REMINDER_YAML.exists():
return
data = yaml.safe_load(REMINDER_YAML.read_text(encoding="utf-8")) or {}
now = datetime.now(TZ).replace(tzinfo=None)
state = _load_state()
fresh: dict[str, str] = {}
for item in data.get("reminders", []):
text = (item.get("text") or "").strip()
if not text:
continue
key = _key(text)
last = state.get(key)
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")
if __name__ == "__main__":
main()