""" Erinnerungen & Routinen (Lucy-Proaktivität, Faden A3). „Lucy, erinner mich morgen um 9 an …" — der Hermes-Agent legt per Tool (mcp_mc.py: reminder_create) einen Eintrag an; der Wecker-Loop hier feuert ihn zur Zeit in den Melde-Briefkasten (announce.py → Lucy spricht) und parallel auf Telegram. Bewusst OHNE Hermes-cron: der Wecker läuft deterministisch im MC2-Backend (systemd, überlebt Gateway-Ausfälle) und bleibt in UNSEREN Schichten (Hermes-Quellcode/Config bleibt Auto-Update-Kanal, Verdikt Faden 11c). Zeiten: ISO-8601. Ohne Offset gilt die Zeitzone des Commanders (MC_LOCAL_TZ, Default Europe/Berlin) — die Box selbst läuft auf UTC, naive Zeiten dürfen daher NIE als Box-Lokalzeit interpretiert werden. Wiederholung: daily | weekdays | weekly. """ import json import logging import os import threading import time from datetime import datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo from config import MODELS_DIR from services import announce log = logging.getLogger(__name__) STORE_PATH = Path(os.environ.get("MC_REMINDERS_STORE", str(MODELS_DIR / "mc2-reminders.json"))) LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) INTERVAL = int(os.environ.get("MC_REMINDERS_INTERVAL", "20")) # Wecker-Tick (Sekunden) MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100")) REPEATS = ("", "daily", "weekdays", "weekly") LATE_NOTE_S = 600 # feuert >10 min zu spät (Box war aus) → ehrlich dazusagen _lock = threading.Lock() _state: dict | None = None # {"next_id": int, "items": [...]} def _load() -> dict: global _state if _state is None: try: _state = json.loads(STORE_PATH.read_text(encoding="utf-8")) assert isinstance(_state.get("next_id"), int) and isinstance(_state.get("items"), list) except Exception: _state = {"next_id": 1, "items": []} return _state def _save(state: dict) -> None: try: tmp = STORE_PATH.with_suffix(".tmp") tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") tmp.replace(STORE_PATH) except OSError: log.warning("reminders: Store %s nicht schreibbar", STORE_PATH, exc_info=True) def _parse_when(when: str) -> datetime: """ISO-8601 → bewusste Zeit. Naive Angaben = Commander-Zeitzone (NICHT Box-UTC).""" try: dt = datetime.fromisoformat(when.strip()) except ValueError: raise ValueError("Zeit nicht lesbar — bitte ISO-8601 mit Datum UND Uhrzeit, z.B. 2026-07-04T09:00.") if dt.tzinfo is None: dt = dt.replace(tzinfo=LOCAL_TZ) return dt def _fmt(ts: float) -> str: """Menschlich lesbare Commander-Lokalzeit (fürs Tool-Echo/Listing).""" return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%a %d.%m.%Y %H:%M") def _advance(ts: float, repeat: str) -> float: """Nächste Wiederholung NACH jetzt (holt verpasste Termine ohne Mehrfach-Feuern auf).""" dt = datetime.fromtimestamp(ts, LOCAL_TZ) now = time.time() while dt.timestamp() <= now: dt += timedelta(days=7 if repeat == "weekly" else 1) if repeat == "weekdays": while dt.weekday() > 4: # Sa/So überspringen dt += timedelta(days=1) return dt.timestamp() def create(text: str, when: str, repeat: str = "") -> dict: text = (text or "").strip() if not text: raise ValueError("Leerer Erinnerungs-Text.") repeat = (repeat or "").strip().lower() if repeat == "once": repeat = "" if repeat not in REPEATS: raise ValueError("repeat muss leer, daily, weekdays oder weekly sein.") ts = _parse_when(when).timestamp() if repeat: if ts <= time.time(): # Startpunkt vorbei → auf die nächste Wiederholung rücken (Uhrzeit bleibt exakt) ts = _advance(ts, repeat) elif ts <= time.time() - 60: raise ValueError(f"Die Zeit liegt in der Vergangenheit ({_fmt(ts)}) — bitte neu umrechnen " f"(jetzt ist {_fmt(time.time())}).") with _lock: state = _load() if len(state["items"]) >= MAX_ITEMS: raise ValueError(f"Zu viele offene Erinnerungen (max {MAX_ITEMS}).") item = {"id": state["next_id"], "text": text[:500], "next_ts": ts, "repeat": repeat, "created_ts": time.time()} state["next_id"] += 1 state["items"].append(item) _save(state) log.info("reminder #%s angelegt: %s → %s%s", item["id"], _fmt(ts), text[:60], f" (Routine {repeat})" if repeat else "") return {**item, "when_local": _fmt(ts)} def list_all() -> list[dict]: with _lock: items = sorted(_load()["items"], key=lambda i: i["next_ts"]) return [{**i, "when_local": _fmt(i["next_ts"])} for i in items] def delete(reminder_id: int) -> dict: with _lock: state = _load() for i, item in enumerate(state["items"]): if item["id"] == reminder_id: state["items"].pop(i) _save(state) return {**item, "when_local": _fmt(item["next_ts"])} raise KeyError(f"Erinnerung {reminder_id} nicht gefunden.") def _fire_due() -> None: now = time.time() with _lock: state = _load() due = [i for i in state["items"] if i["next_ts"] <= now] if not due: return for item in due: if item["repeat"]: item["next_ts"] = _advance(item["next_ts"], item["repeat"]) else: state["items"].remove(item) _save(state) for item in due: late = now - item["next_ts"] > LATE_NOTE_S if not item["repeat"] else False text = f"Erinnerung, Commander: {item['text']}" if late: text += f" (Eigentlich fällig um {_fmt(item['next_ts'])} — die Box war wohl aus.)" announce.add(text, subject="[Erinnerung]", source="reminder") announce.notify_telegram("[Erinnerung]", item["text"]) log.info("reminder #%s gefeuert%s", item["id"], " (Routine)" if item["repeat"] else "") async def reminders_loop() -> None: """Wecker (Hintergrund-Task im MC2-Lifespan). Verpasste einmalige Erinnerungen (Box war aus) feuern beim nächsten Tick nach — ehrlich als verspätet markiert.""" import asyncio log.info("reminders: Wecker aktiv (Tick %ss, Zeitzone %s)", INTERVAL, LOCAL_TZ) while True: try: await asyncio.to_thread(_fire_due) except Exception: log.debug("reminders: Tick fehlgeschlagen", exc_info=True) await asyncio.sleep(INTERVAL)