Zwei Fehler, die zusammen wie "der Timer hat nicht gefeuert" aussahen. 1) Trigger hatten KEIN Projekt. Beim Feuern ging die Antwort ohne projectId durch _process_core_response — also immer in den Hauptchat, auch wenn der Timer mitten in einem Projekt-Thread gestellt wurde. Im Projekt blieb es still, und es sah aus wie "nicht gefeuert". Jetzt wird das Projekt des Requests mit angelegt, der getriggerte Turn laeuft darin, und die Antwort kommt dort zurueck. 2) trigger_list zeigte nur "aktiv" / "DEAKTIVIERT" und verschwieg fire_count und last_fired_at. Ein One-Shot-Timer wird NACH dem Feuern automatisch deaktiviert — "hat sauber gefeuert" und "war nie an" sahen damit identisch aus. ARIA hat genau das falsch gelesen und berichtet, sie habe vergessen den Timer zu aktivieren, obwohl er puenktlich gefeuert hatte. Die Ausgabe nennt jetzt ausdruecklich "SCHON GEFEUERT (Nx, zuletzt <Zeit>)" plus den Kontext, in dem die Antwort landet. Nachgewiesen am Fall flugwetter-whv-freitag-0900: gefeuert 07:00:05 UTC (= 09:00 lokal), Antwort 46s spaeter, fire_count=1 — aber active=false im Manifest und die Antwort im Hauptchat statt im Projekt-Thread. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
264 lines
8.5 KiB
Python
264 lines
8.5 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",
|
|
project_id: str = "",
|
|
) -> 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,
|
|
# Projekt, in dem der Trigger angelegt wurde. Ohne das landete die
|
|
# Antwort beim Feuern IMMER im Hauptchat — auch wenn der Timer mitten
|
|
# in einem Projekt-Thread gestellt wurde. Leer = Hauptchat.
|
|
"project_id": project_id or "",
|
|
"fire_count": 0,
|
|
"last_fired_at": None,
|
|
}
|
|
write(name, data)
|
|
logger.info("Trigger angelegt: %s (timer, fires_at=%s, project=%s)",
|
|
name, fires_at_iso, project_id or "(main)")
|
|
return data
|
|
|
|
|
|
def create_watcher(
|
|
name: str,
|
|
condition: str,
|
|
message: str,
|
|
check_interval_sec: int = 300,
|
|
throttle_sec: int = 3600,
|
|
author: str = "aria",
|
|
project_id: str = "",
|
|
) -> 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,
|
|
# Siehe create_timer: ohne Projekt landet die Antwort im Hauptchat.
|
|
"project_id": project_id or "",
|
|
"fire_count": 0,
|
|
"last_fired_at": None,
|
|
"last_checked_at": None,
|
|
}
|
|
write(name, data)
|
|
logger.info("Trigger angelegt: %s (watcher, cond='%s', project=%s)",
|
|
name, condition, project_id or "(main)")
|
|
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 []
|