- Trigger-Zeit (Issue 4): fires_at wird beim Anlegen IMMER auf UTC normalisiert (naiv = lokale Wanduhrzeit Europe/Berlin → UTC, aware → astimezone). Passt zur UTC-Ausfuehrung in background.py → feuert zur gemeinten Ortszeit, zeitzonen- portabel. Tool-Desc + Prompt: fires_at = lokale Wanduhrzeit ohne Zone schreiben, Server rechnet um. Behebt den 2h-Versatz. - project_summary (Issue 2): liest jetzt auch den Hauptchat (name='Hauptchat') und die Beschreibung sagt ARIA, es fuer 'schau mal in Projekt X / Hauptchat rein' / 'hol dir die Infos aus ...' zu nutzen. - Privatkopie (Issue 3): IDENTITY_ANCHOR-Regel — YouTube-Downloads fuer Stefans PRIVATgebrauch sind eine Privatkopie (§53 UrhG), nicht reflexartig ablehnen/ moralisieren; Urteil bleibt fuer Weiterverbreitung/kommerziell. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
254 lines
7.9 KiB
Python
254 lines
7.9 KiB
Python
"""
|
|
Triggers — passive Aufweck-Quellen fuer ARIA.
|
|
|
|
Skills sind aktiv (ARIA ruft sie). Triggers sind passiv — das System ruft
|
|
ARIA wenn ein Event passiert. Drei Typen:
|
|
|
|
timer Einmalig zu einem festen Zeitpunkt
|
|
watcher Recurring: Condition pruefen, bei True → feuern (mit Throttle)
|
|
cron Cron-Expression (vorerst nicht implementiert, Platzhalter)
|
|
|
|
Layout:
|
|
/data/triggers/<name>.json Manifest pro Trigger
|
|
/data/triggers/logs/<name>.jsonl Append-only Log pro Feuerung
|
|
|
|
Polling-Kosten: Brain-internes Background-Polling (kein LLM-Call).
|
|
ARIA wird nur aufgeweckt wenn ein Trigger tatsaechlich feuert.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TRIGGERS_DIR = Path(os.environ.get("TRIGGERS_DIR", "/data/triggers"))
|
|
LOGS_DIR = TRIGGERS_DIR / "logs"
|
|
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,60}$")
|
|
VALID_TYPES = {"timer", "watcher", "cron"}
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _local_offset_hours(dt: datetime) -> int:
|
|
"""Grobe Europe/Berlin-Naeherung (CEST=+2 Maerz-Okt, sonst CET=+1) — dieselbe
|
|
Logik wie build_time_section im Prompt, ohne zoneinfo/tzdata im Brain-Image."""
|
|
return 2 if 3 <= dt.month <= 10 else 1
|
|
|
|
|
|
def normalize_fires_at_utc(iso: str) -> str:
|
|
"""Bringt einen fires_at-ISO IMMER auf UTC (+00:00).
|
|
|
|
- Aware (endet auf Z oder hat einen Offset) → in UTC umgerechnet.
|
|
- Naiv (keine Zone) → als LOKALE Wanduhrzeit (Europe/Berlin) interpretiert
|
|
und nach UTC umgerechnet.
|
|
|
|
So speichern wir stets den absoluten Instant. Die Ausfuehrung (background.py,
|
|
UTC) trifft damit exakt die vom Nutzer gemeinte Ortszeit — und bleibt
|
|
zeitzonen-portabel (feuert am selben Moment, egal wo Stefan gerade ist)."""
|
|
dt = datetime.fromisoformat((iso or "").strip().replace("Z", "+00:00"))
|
|
if dt.tzinfo is None:
|
|
# Naiv = lokale Wanduhrzeit → UTC = lokal - Offset.
|
|
dt = (dt - timedelta(hours=_local_offset_hours(dt))).replace(tzinfo=timezone.utc)
|
|
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
|
|
|
|
|
def _safe_name(name: str) -> str:
|
|
if not isinstance(name, str) or not NAME_RE.match(name):
|
|
raise ValueError(f"Ungueltiger Trigger-Name: {name!r}")
|
|
return name
|
|
|
|
|
|
def _path(name: str) -> Path:
|
|
return TRIGGERS_DIR / f"{_safe_name(name)}.json"
|
|
|
|
|
|
def _ensure_dirs():
|
|
TRIGGERS_DIR.mkdir(parents=True, exist_ok=True)
|
|
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
# ─── CRUD ───────────────────────────────────────────────────────────
|
|
|
|
def list_triggers(active_only: bool = False) -> list[dict]:
|
|
if not TRIGGERS_DIR.exists():
|
|
return []
|
|
out: list[dict] = []
|
|
for f in sorted(TRIGGERS_DIR.glob("*.json")):
|
|
try:
|
|
data = json.loads(f.read_text(encoding="utf-8"))
|
|
if active_only and not data.get("active", True):
|
|
continue
|
|
out.append(data)
|
|
except Exception as e:
|
|
logger.warning("Trigger lesen %s: %s", f, e)
|
|
return out
|
|
|
|
|
|
def read(name: str) -> Optional[dict]:
|
|
p = _path(name)
|
|
if not p.exists():
|
|
return None
|
|
try:
|
|
return json.loads(p.read_text(encoding="utf-8"))
|
|
except Exception as e:
|
|
logger.warning("Trigger %s lesen: %s", name, e)
|
|
return None
|
|
|
|
|
|
def write(name: str, data: dict) -> None:
|
|
_ensure_dirs()
|
|
data["updated_at"] = _now_iso()
|
|
p = _path(name)
|
|
tmp = p.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
tmp.replace(p)
|
|
|
|
|
|
def delete(name: str) -> None:
|
|
p = _path(name)
|
|
if not p.exists():
|
|
raise ValueError(f"Trigger '{name}' nicht gefunden")
|
|
p.unlink()
|
|
# Logs auch wegraeumen
|
|
log_file = LOGS_DIR / f"{_safe_name(name)}.jsonl"
|
|
if log_file.exists():
|
|
log_file.unlink()
|
|
|
|
|
|
def update(name: str, patch: dict) -> dict:
|
|
data = read(name)
|
|
if data is None:
|
|
raise ValueError(f"Trigger '{name}' nicht gefunden")
|
|
allowed = {"active", "message", "condition", "throttle_sec",
|
|
"check_interval_sec", "fires_at"}
|
|
for k, v in patch.items():
|
|
if k in allowed:
|
|
data[k] = v
|
|
write(name, data)
|
|
return data
|
|
|
|
|
|
# ─── Create-Helpers (typ-spezifisch) ────────────────────────────────
|
|
|
|
def create_timer(
|
|
name: str,
|
|
fires_at_iso: str,
|
|
message: str,
|
|
author: str = "aria",
|
|
) -> dict:
|
|
_safe_name(name)
|
|
if _path(name).exists():
|
|
raise ValueError(f"Trigger '{name}' existiert schon")
|
|
# ISO validieren UND auf UTC normalisieren (naiv = lokale Wanduhrzeit →
|
|
# UTC). So passt das Anlegen zur UTC-Ausfuehrung in background.py.
|
|
try:
|
|
fires_at_iso = normalize_fires_at_utc(fires_at_iso)
|
|
except Exception:
|
|
raise ValueError(f"fires_at_iso ungueltig: {fires_at_iso}")
|
|
data = {
|
|
"name": name,
|
|
"type": "timer",
|
|
"active": True,
|
|
"author": author,
|
|
"created_at": _now_iso(),
|
|
"fires_at": fires_at_iso,
|
|
"message": message,
|
|
"fire_count": 0,
|
|
"last_fired_at": None,
|
|
}
|
|
write(name, data)
|
|
logger.info("Trigger angelegt: %s (timer, fires_at=%s)", name, fires_at_iso)
|
|
return data
|
|
|
|
|
|
def create_watcher(
|
|
name: str,
|
|
condition: str,
|
|
message: str,
|
|
check_interval_sec: int = 300,
|
|
throttle_sec: int = 3600,
|
|
author: str = "aria",
|
|
) -> dict:
|
|
_safe_name(name)
|
|
if _path(name).exists():
|
|
raise ValueError(f"Trigger '{name}' existiert schon")
|
|
# Condition parsen-pruefen (wirft bei Syntax-Fehler)
|
|
from watcher import parse_condition
|
|
parse_condition(condition) # nur Validate
|
|
if check_interval_sec < 30:
|
|
check_interval_sec = 30 # nicht oefter als alle 30s pruefen
|
|
if throttle_sec < 0:
|
|
throttle_sec = 0
|
|
data = {
|
|
"name": name,
|
|
"type": "watcher",
|
|
"active": True,
|
|
"author": author,
|
|
"created_at": _now_iso(),
|
|
"condition": condition,
|
|
"check_interval_sec": int(check_interval_sec),
|
|
"throttle_sec": int(throttle_sec),
|
|
"message": message,
|
|
"fire_count": 0,
|
|
"last_fired_at": None,
|
|
"last_checked_at": None,
|
|
}
|
|
write(name, data)
|
|
logger.info("Trigger angelegt: %s (watcher, cond='%s')", name, condition)
|
|
return data
|
|
|
|
|
|
# ─── Feuern + Log ───────────────────────────────────────────────────
|
|
|
|
def mark_fired(name: str) -> dict:
|
|
data = read(name)
|
|
if data is None:
|
|
raise ValueError(f"Trigger '{name}' nicht gefunden")
|
|
data["fire_count"] = int(data.get("fire_count", 0)) + 1
|
|
data["last_fired_at"] = _now_iso()
|
|
# Timer: nach Feuern auto-deaktivieren (one-shot)
|
|
if data.get("type") == "timer":
|
|
data["active"] = False
|
|
write(name, data)
|
|
return data
|
|
|
|
|
|
def append_log(name: str, entry: dict) -> None:
|
|
_ensure_dirs()
|
|
log_file = LOGS_DIR / f"{_safe_name(name)}.jsonl"
|
|
record = {"ts": _now_iso()}
|
|
record.update(entry)
|
|
try:
|
|
with log_file.open("a", encoding="utf-8") as f:
|
|
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
except Exception as e:
|
|
logger.warning("Trigger-Log append %s: %s", name, e)
|
|
|
|
|
|
def list_logs(name: str, limit: int = 50) -> list[dict]:
|
|
log_file = LOGS_DIR / f"{_safe_name(name)}.jsonl"
|
|
if not log_file.exists():
|
|
return []
|
|
try:
|
|
lines = log_file.read_text(encoding="utf-8").splitlines()
|
|
out: list[dict] = []
|
|
for line in lines[-limit:]:
|
|
try:
|
|
out.append(json.loads(line))
|
|
except Exception:
|
|
pass
|
|
return out
|
|
except Exception:
|
|
return []
|