Zalohovani vsech podstatnych souboru

This commit is contained in:
lachtan
2026-06-10 06:39:52 +02:00
parent 1e10891945
commit 67e29c8b88
69 changed files with 9115 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
"""Deterministic random fire-time computation for reminders.
Shared by remind_send.py (runtime) and remind_edit.py (validation). Stdlib only,
so it imports cleanly regardless of the caller's uv/PEP 723 environment.
A reminder's `random` block produces `times_per_day` fire times inside a daily
`window`, spaced at least MIN_GAP_MIN apart. The times are random but fully
determined by (date, text): any script computing them for the same day gets the
same result, so no state needs to be persisted.
"""
from __future__ import annotations
import random
from datetime import date, datetime, time
MIN_GAP_MIN = 15 # minimum gap between fire times in minutes; tune here
def compute_fire_times(target_date: date, text: str, cfg: dict) -> list[datetime]:
"""Deterministic fire times for one day.
Returns [] when the day falls outside the days/from/until filters. Raises
ValueError on a malformed config (bad window, days, dates, or when the
requested count cannot fit the window with MIN_GAP_MIN spacing) — these are
structural and validated before any date filter, so the same call validates
a config regardless of the date passed in.
"""
count = _parse_count(cfg.get("times_per_day"))
start, end = _parse_window(cfg.get("window"))
day_set = _parse_days(cfg["days"]) if cfg.get("days") is not None else None
from_date = _parse_date(cfg["from"]) if cfg.get("from") is not None else None
until_date = _parse_date(cfg["until"]) if cfg.get("until") is not None else None
total = end - start
required = (count - 1) * MIN_GAP_MIN
if required > total:
raise ValueError(
f"{count} times with a {MIN_GAP_MIN}-min gap need {required} min, "
f"but the window is only {total} min wide"
)
if from_date is not None and target_date < from_date:
return []
if until_date is not None and target_date > until_date:
return []
if day_set is not None and _cron_weekday(target_date) not in day_set:
return []
slack = total - required
rnd = random.Random(f"{target_date.isoformat()}|{text}")
offsets = sorted(rnd.randint(0, slack) for _ in range(count))
minutes = [start + offset + index * MIN_GAP_MIN for index, offset in enumerate(offsets)]
return [datetime.combine(target_date, _minute_to_time(m)) for m in minutes]
def _parse_count(raw: object) -> int:
if not isinstance(raw, int) or isinstance(raw, bool) or raw < 1:
raise ValueError(f"times_per_day must be an int >= 1, got {raw!r}")
return raw
def _parse_window(raw: object) -> tuple[int, int]:
if not isinstance(raw, str) or "-" not in raw:
raise ValueError(f"window must be 'HH:MM-HH:MM', got {raw!r}")
start_str, end_str = raw.split("-", 1)
start = _hhmm_to_minutes(start_str.strip())
end = _hhmm_to_minutes(end_str.strip())
if start >= end:
raise ValueError(f"window start must be before end: {raw!r}")
return start, end
def _hhmm_to_minutes(value: str) -> int:
parts = value.split(":")
if len(parts) != 2:
raise ValueError(f"invalid time {value!r}, expected HH:MM")
hours, minutes = int(parts[0]), int(parts[1])
if not (0 <= hours < 24 and 0 <= minutes < 60):
raise ValueError(f"time out of range: {value!r}")
return hours * 60 + minutes
def _minute_to_time(total_minutes: int) -> time:
return time(total_minutes // 60, total_minutes % 60)
def _parse_date(raw: object) -> date:
if isinstance(raw, datetime):
return raw.date()
if isinstance(raw, date):
return raw
return date.fromisoformat(str(raw))
def _parse_days(spec: object) -> set[int]:
"""Parse a cron day-of-week spec into a set of cron weekdays (0/7=Sun, 1=Mon..6=Sat)."""
text = str(spec).strip()
if text == "*":
return set(range(7))
result: set[int] = set()
for part in text.split(","):
part = part.strip()
if "-" in part:
low_str, high_str = part.split("-", 1)
low, high = int(low_str), int(high_str)
result.update(_normalize_dow(day) for day in range(low, high + 1))
else:
result.add(_normalize_dow(int(part)))
return result
def _normalize_dow(value: int) -> int:
"""cron allows 7 for Sunday; normalize it to 0."""
if not 0 <= value <= 7:
raise ValueError(f"day-of-week out of range (0-7): {value}")
return 0 if value == 7 else value
def _cron_weekday(target: date) -> int:
"""Map Python weekday (Mon=0..Sun=6) to cron weekday (Sun=0, Mon=1..Sat=6)."""
return (target.weekday() + 1) % 7

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["croniter", "pyyaml"]
# ///
"""Deterministic CRUD for reminder.yaml.
CLI tool for LLM skills to create, list, and remove reminders atomically.
Never edits reminder.yaml directly — always writes to a .tmp file and renames.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import date, datetime
from pathlib import Path
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"
def _load() -> dict:
if not REMINDER_YAML.exists():
return {"reminders": []}
data = yaml.safe_load(REMINDER_YAML.read_text(encoding="utf-8")) or {}
if "reminders" not in data:
data["reminders"] = []
return data
def _save(data: dict) -> None:
tmp = REMINDER_YAML.with_suffix(".yaml.tmp")
tmp.write_text(
yaml.safe_dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False),
encoding="utf-8",
)
os.replace(tmp, REMINDER_YAML)
def cmd_list(_args: argparse.Namespace) -> int:
data = _load()
print(json.dumps({"reminders": data["reminders"]}, ensure_ascii=False))
return 0
def cmd_add(args: argparse.Namespace) -> int:
text = (args.text or "").strip()
if not text:
print(json.dumps({"error": "text must not be empty"}), file=sys.stderr)
return 1
try:
random_cfg = _build_random(args)
except ValueError as exc:
print(json.dumps({"error": str(exc)}), file=sys.stderr)
return 1
if not args.at and not args.cron and not random_cfg:
print(json.dumps({"error": "provide --cron, --at, or --random-* options"}), file=sys.stderr)
return 1
item: dict = {"text": text}
if args.at:
for at_str in args.at:
try:
datetime.fromisoformat(at_str)
except ValueError as exc:
print(json.dumps({"error": f"invalid --at datetime: {exc}"}), file=sys.stderr)
return 1
if len(args.at) == 1:
item["at"] = args.at[0]
else:
item["at_times"] = args.at
if args.cron:
for expr in args.cron:
if not croniter.is_valid(expr):
print(json.dumps({"error": f"invalid cron expression: {expr!r}"}), file=sys.stderr)
return 1
item["cron_exprs"] = args.cron
if random_cfg:
item["random"] = random_cfg
data = _load()
data["reminders"].append(item)
_save(data)
print(json.dumps({"added": item}, ensure_ascii=False))
return 0
def _build_random(args: argparse.Namespace) -> dict | None:
"""Assemble and validate the random schedule block, or None if no --random-* flag given."""
fields = {
"times_per_day": args.random_times_per_day,
"window": args.random_window,
"days": args.random_days,
"from": args.random_from,
"until": args.random_until,
}
if all(value is None for value in fields.values()):
return None
if fields["times_per_day"] is None or fields["window"] is None:
raise ValueError("random schedule needs --random-times-per-day and --random-window")
cfg = {key: value for key, value in fields.items() if value is not None}
compute_fire_times(date(2000, 1, 1), "validation", cfg) # raises ValueError on a bad config
return cfg
def cmd_remove(args: argparse.Namespace) -> int:
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
return 1
data = _load()
matches = [r for r in data["reminders"] if keyword in (r.get("text") or "").lower()]
if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
return 1
removed = matches[0]
data["reminders"] = [r for r in data["reminders"] if r is not removed]
_save(data)
print(json.dumps({"removed": removed}, ensure_ascii=False))
return 0
def main() -> None:
parser = argparse.ArgumentParser(description="CRUD for reminder.yaml")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("list", help="List all reminders as JSON")
add_p = sub.add_parser("add", help="Add a new reminder")
add_p.add_argument("--text", required=True, help="Reminder text")
add_p.add_argument("--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)")
add_p.add_argument("--at", action="append", metavar="ISO_DATETIME", help="One-time datetime ISO 8601 (repeatable, combinable with --cron)")
add_p.add_argument("--random-times-per-day", type=int, dest="random_times_per_day", metavar="N", help="Random schedule: fires per day")
add_p.add_argument("--random-window", dest="random_window", metavar="HH:MM-HH:MM", help="Random schedule: daily time window")
add_p.add_argument("--random-days", dest="random_days", metavar="DOW", help="Random schedule: cron day-of-week filter, e.g. '1-5' (optional)")
add_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD", help="Random schedule: start date, inclusive (optional)")
add_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD", help="Random schedule: end date, inclusive (optional)")
remove_p = sub.add_parser("remove", help="Remove a reminder by keyword")
remove_p.add_argument("--keyword", required=True, help="Substring to match against reminder text")
args = parser.parse_args()
dispatch = {"list": cmd_list, "add": cmd_add, "remove": cmd_remove}
sys.exit(dispatch[args.command](args))
if __name__ == "__main__":
main()

View 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()