Zalohovani vsech podstatnych souboru
This commit is contained in:
150
skills/remind/scripts/remind_send.py
Normal file
150
skills/remind/scripts/remind_send.py
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user