151 lines
4.9 KiB
Python
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()
|