# -*- coding: utf-8 -*- """ AdminCommands Plugin. Stellt 27 Admin-Only Intents bereit fuer System-Info, Satellite-Verwaltung, Plugin-Status, Training-Steuerung und History/Log-Abfragen. Alle Intents erfordern das System-Command Wakeword. Patterns und Examples sind in intents_de.yml definiert (multilingual). """ from __future__ import annotations import asyncio import logging import os import socket import time from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING, Any from trixy_core.plugins.trixy_plugin import TrixyPlugin from trixy_core.nlp.decorators import intent, admin_only from trixy_core.nlp.handler import IntentResult, IntentReceivedData from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn if TYPE_CHECKING: from trixy_core.activity.tracker import ActivityTracker # Audit-Logger fuer Admin-Befehle _audit_logger: logging.Logger | None = None def _get_audit_logger() -> logging.Logger: """Liefert den Audit-Logger (lazy init).""" global _audit_logger if _audit_logger is None: _audit_logger = logging.getLogger("admin_audit") _audit_logger.setLevel(logging.INFO) if not _audit_logger.handlers: log_dir = Path("logs") log_dir.mkdir(exist_ok=True) handler = logging.FileHandler(log_dir / "admin_audit.log", encoding="utf-8") handler.setFormatter(logging.Formatter( "%(asctime)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S" )) _audit_logger.addHandler(handler) return _audit_logger def _audit(intent_name: str, satellite_id: str, room_id: str, detail: str = "") -> None: """Schreibt einen Audit-Eintrag.""" logger = _get_audit_logger() msg = f"ADMIN | intent={intent_name} | satellite={satellite_id} | room={room_id}" if detail: msg += f" | {detail}" logger.info(msg) class AdminCommandsPlugin(TrixyPlugin): """Admin-Befehle fuer System-Verwaltung per Sprache.""" NAME = "admin_commands" VERSION = "1.0.0" DESCRIPTION = "Admin-Only Sprachbefehle fuer System, Satellites, Training und Logs" AUTHOR = "Trixy" def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self._start_time: float = 0.0 async def on_load(self) -> None: """Plugin geladen — Startzeit merken.""" self._start_time = time.time() pinfo("[AdminCommands] Plugin geladen") async def on_unload(self) -> None: """Plugin entladen.""" pdebug("[AdminCommands] Plugin entladen") def _get_tracker(self) -> "ActivityTracker | None": """Liefert den ActivityTracker Service.""" try: return self.application.services.get_service("ActivityTracker") except Exception: return None # ========================================================================= # System Info (1-6) # ========================================================================= @intent("admin_hostname_query", description="Hostname abfragen") @admin_only async def handle_hostname_query(self, data: IntentReceivedData) -> IntentResult: """Gibt den Hostnamen zurueck.""" _audit("admin_hostname_query", data.satellite_id, data.room_id) hostname = socket.gethostname() return IntentResult.success_with_response(f"Der Hostname ist {hostname}.") @intent("admin_domain_query", description="Domain abfragen") @admin_only async def handle_domain_query(self, data: IntentReceivedData) -> IntentResult: """Gibt den FQDN zurueck.""" _audit("admin_domain_query", data.satellite_id, data.room_id) fqdn = socket.getfqdn() return IntentResult.success_with_response(f"Die Domain ist {fqdn}.") @intent("admin_ip_query", description="IP-Adresse abfragen") @admin_only async def handle_ip_query(self, data: IntentReceivedData) -> IntentResult: """Gibt die IP-Adresse zurueck.""" _audit("admin_ip_query", data.satellite_id, data.room_id) ip = self._get_primary_ip() return IntentResult.success_with_response(f"Die IP-Adresse ist {ip}.") @intent("admin_uptime_query", description="Server-Laufzeit abfragen") @admin_only async def handle_uptime_query(self, data: IntentReceivedData) -> IntentResult: """Gibt die Server-Laufzeit zurueck.""" _audit("admin_uptime_query", data.satellite_id, data.room_id) uptime_seconds = time.time() - self._start_time text = self._format_duration(uptime_seconds) return IntentResult.success_with_response(f"Der Server laeuft seit {text}.") @intent("admin_system_time_set", description="Systemuhrzeit setzen") @admin_only async def handle_system_time_set(self, data: IntentReceivedData, time_value: str = "") -> IntentResult: """Setzt die Systemuhrzeit.""" _audit("admin_system_time_set", data.satellite_id, data.room_id, f"time={time_value}") if not time_value: return IntentResult.failure("Keine Uhrzeit angegeben.", "Bitte eine Uhrzeit angeben.") try: proc = await asyncio.create_subprocess_exec( "timedatectl", "set-time", time_value, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) _, stderr = await proc.communicate() if proc.returncode != 0: err = stderr.decode().strip() return IntentResult.failure(err, f"Fehler beim Setzen der Uhrzeit: {err}") return IntentResult.success_with_response(f"Uhrzeit auf {time_value} gesetzt.") except Exception as e: return IntentResult.failure(str(e), f"Fehler: {e}") @intent("admin_system_date_set", description="Systemdatum setzen") @admin_only async def handle_system_date_set(self, data: IntentReceivedData, date_value: str = "") -> IntentResult: """Setzt das Systemdatum.""" _audit("admin_system_date_set", data.satellite_id, data.room_id, f"date={date_value}") if not date_value: return IntentResult.failure("Kein Datum angegeben.", "Bitte ein Datum angeben.") try: proc = await asyncio.create_subprocess_exec( "timedatectl", "set-time", date_value, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) _, stderr = await proc.communicate() if proc.returncode != 0: err = stderr.decode().strip() return IntentResult.failure(err, f"Fehler beim Setzen des Datums: {err}") return IntentResult.success_with_response(f"Datum auf {date_value} gesetzt.") except Exception as e: return IntentResult.failure(str(e), f"Fehler: {e}") # ========================================================================= # Satellite Management (7-13) # ========================================================================= @intent("admin_registration_enable", description="Registrierungsmodus aktivieren") @admin_only async def handle_registration_enable(self, data: IntentReceivedData) -> IntentResult: """Aktiviert den Registrierungsmodus.""" _audit("admin_registration_enable", data.satellite_id, data.room_id) timeout = self.get_config_value("registration_timeout_seconds", 60) try: self.application.registration.enable_registration_mode(timeout) return IntentResult.success_with_response( f"Registrierungsmodus aktiviert fuer {timeout} Sekunden." ) except Exception as e: return IntentResult.failure(str(e), f"Fehler: {e}") @intent("admin_registration_disable", description="Registrierungsmodus deaktivieren") @admin_only async def handle_registration_disable(self, data: IntentReceivedData) -> IntentResult: """Deaktiviert den Registrierungsmodus.""" _audit("admin_registration_disable", data.satellite_id, data.room_id) try: self.application.registration.disable_registration_mode() return IntentResult.success_with_response("Registrierungsmodus deaktiviert.") except Exception as e: return IntentResult.failure(str(e), f"Fehler: {e}") @intent("admin_registration_status", description="Registrierungsmodus Status") @admin_only async def handle_registration_status(self, data: IntentReceivedData) -> IntentResult: """Gibt den Status des Registrierungsmodus zurueck.""" _audit("admin_registration_status", data.satellite_id, data.room_id) try: active = self.application.registration.registration_mode if active: return IntentResult.success_with_response("Ja, der Registrierungsmodus ist aktiv.") return IntentResult.success_with_response("Nein, der Registrierungsmodus ist nicht aktiv.") except Exception as e: return IntentResult.failure(str(e), f"Fehler: {e}") @intent("admin_satellite_count_registered", description="Anzahl registrierter Satellites") @admin_only async def handle_satellite_count_registered(self, data: IntentReceivedData) -> IntentResult: """Gibt die Anzahl registrierter Satellites zurueck.""" _audit("admin_satellite_count_registered", data.satellite_id, data.room_id) try: count = self.application.satellites.count return IntentResult.success_with_response( f"Es {'ist' if count == 1 else 'sind'} {count} Satellite{'s' if count != 1 else ''} registriert." ) except Exception as e: return IntentResult.failure(str(e), f"Fehler: {e}") @intent("admin_satellite_count_connected", description="Anzahl verbundener Satellites") @admin_only async def handle_satellite_count_connected(self, data: IntentReceivedData) -> IntentResult: """Gibt die Anzahl verbundener Satellites zurueck.""" _audit("admin_satellite_count_connected", data.satellite_id, data.room_id) try: count = self.application.satellites.connected_count return IntentResult.success_with_response( f"Es {'ist' if count == 1 else 'sind'} {count} Satellite{'s' if count != 1 else ''} verbunden." ) except Exception as e: return IntentResult.failure(str(e), f"Fehler: {e}") @intent("admin_satellite_last_connected", description="Letzte Verbindung eines Satellites") @admin_only async def handle_satellite_last_connected(self, data: IntentReceivedData, alias: str = "") -> IntentResult: """Gibt die letzte Verbindungszeit eines Satellites zurueck.""" _audit("admin_satellite_last_connected", data.satellite_id, data.room_id, f"alias={alias}") if not alias: return IntentResult.failure("Kein Alias angegeben.", "Bitte einen Satellite-Namen angeben.") tracker = self._get_tracker() if not tracker: return IntentResult.failure("ActivityTracker nicht verfuegbar.") history = tracker.get_satellite_connection_history(alias) if not history: return IntentResult.success_with_response(f"Keine Verbindungshistorie fuer {alias} gefunden.") last = history[-1] connected_at = last.get("connected_at", "unbekannt") try: dt = datetime.fromisoformat(connected_at) formatted = dt.strftime("%d.%m.%Y um %H:%M Uhr") except (ValueError, TypeError): formatted = connected_at return IntentResult.success_with_response(f"{alias} war zuletzt am {formatted} verbunden.") @intent("admin_satellite_unregister", description="Satellite-Registrierung loeschen") @admin_only async def handle_satellite_unregister(self, data: IntentReceivedData, alias: str = "") -> IntentResult: """Loescht die Registrierung eines Satellites.""" _audit("admin_satellite_unregister", data.satellite_id, data.room_id, f"alias={alias}") if not alias: return IntentResult.failure("Kein Alias angegeben.", "Bitte einen Satellite-Namen angeben.") try: # Satellite nach Alias suchen satellite = None for s in self.application.satellites.get_all(): if s.alias.lower() == alias.lower(): satellite = s break if not satellite: return IntentResult.success_with_response(f"Kein Satellite mit dem Namen {alias} gefunden.") # Registrierung loeschen if self.application.registration.delete_registration(satellite.mac_address): self.application.satellites.remove(satellite.id) return IntentResult.success_with_response(f"Registrierung von {alias} wurde geloescht.") return IntentResult.failure("Loeschen fehlgeschlagen.", f"Registrierung von {alias} konnte nicht geloescht werden.") except Exception as e: return IntentResult.failure(str(e), f"Fehler: {e}") # ========================================================================= # Plugin Management (14) # ========================================================================= @intent("admin_plugin_count", description="Anzahl aktiver Plugins") @admin_only async def handle_plugin_count(self, data: IntentReceivedData) -> IntentResult: """Gibt die Anzahl geladener/aktiver Plugins zurueck.""" _audit("admin_plugin_count", data.satellite_id, data.room_id) try: total = self.application.plugins.count enabled = self.application.plugins.enabled_count return IntentResult.success_with_response( f"Es sind {total} Plugins geladen, davon {enabled} aktiv." ) except Exception as e: return IntentResult.failure(str(e), f"Fehler: {e}") # ========================================================================= # Training (15-19) # ========================================================================= @intent("admin_training_status", description="Trainings-Status abfragen") @admin_only async def handle_training_status(self, data: IntentReceivedData) -> IntentResult: """Gibt den aktuellen Trainings-Status zurueck.""" _audit("admin_training_status", data.satellite_id, data.room_id) try: trainer_service = self.application.services.get_service("TrainerService") active = trainer_service.active_trainer_id if active: progress = trainer_service.current_progress return IntentResult.success_with_response( f"Ja, Training '{active}' laeuft. Status: {progress.state.name}." ) return IntentResult.success_with_response("Nein, es laeuft kein Training.") except Exception as e: return IntentResult.failure(str(e), f"Fehler: {e}") @intent("admin_training_start", description="Training starten") @admin_only async def handle_training_start(self, data: IntentReceivedData, model_type: str = "") -> IntentResult: """Startet ein Training.""" _audit("admin_training_start", data.satellite_id, data.room_id, f"model_type={model_type}") if not model_type: return IntentResult.failure("Kein Modelltyp angegeben.", "Bitte einen Modelltyp angeben.") try: trainer_service = self.application.services.get_service("TrainerService") success, error = await trainer_service.start_training(model_type) if success: return IntentResult.success_with_response(f"Training '{model_type}' wurde gestartet.") return IntentResult.failure(error, f"Training konnte nicht gestartet werden: {error}") except Exception as e: return IntentResult.failure(str(e), f"Fehler: {e}") @intent("admin_training_stop", description="Training stoppen") @admin_only async def handle_training_stop(self, data: IntentReceivedData) -> IntentResult: """Stoppt das laufende Training.""" _audit("admin_training_stop", data.satellite_id, data.room_id) try: trainer_service = self.application.services.get_service("TrainerService") success, error = await trainer_service.stop_training() if success: return IntentResult.success_with_response("Training wurde gestoppt.") return IntentResult.failure(error, f"Training konnte nicht gestoppt werden: {error}") except Exception as e: return IntentResult.failure(str(e), f"Fehler: {e}") @intent("admin_training_last", description="Letztes Training abfragen") @admin_only async def handle_training_last(self, data: IntentReceivedData) -> IntentResult: """Gibt Informationen zum letzten Training zurueck.""" _audit("admin_training_last", data.satellite_id, data.room_id) tracker = self._get_tracker() if not tracker: return IntentResult.failure("ActivityTracker nicht verfuegbar.") last = tracker.get_last_training() if not last: return IntentResult.success_with_response("Es wurde noch kein Training durchgefuehrt.") return IntentResult.success_with_response(self._format_training_entry(last)) @intent("admin_training_last_specific", description="Letztes Training nach Typ abfragen") @admin_only async def handle_training_last_specific(self, data: IntentReceivedData, model_type: str = "") -> IntentResult: """Gibt Informationen zum letzten Training eines bestimmten Typs zurueck.""" _audit("admin_training_last_specific", data.satellite_id, data.room_id, f"model_type={model_type}") if not model_type: return IntentResult.failure("Kein Modelltyp angegeben.", "Bitte einen Modelltyp angeben.") tracker = self._get_tracker() if not tracker: return IntentResult.failure("ActivityTracker nicht verfuegbar.") last = tracker.get_last_training(model_type) if not last: return IntentResult.success_with_response(f"Es wurde noch kein '{model_type}' Training durchgefuehrt.") return IntentResult.success_with_response(self._format_training_entry(last)) # ========================================================================= # History / Logs (20-27) # ========================================================================= @intent("admin_last_requests", description="Letzte Anfragen anzeigen") @admin_only async def handle_last_requests(self, data: IntentReceivedData, count: int = 5) -> IntentResult: """Gibt die letzten N Anfragen zurueck.""" _audit("admin_last_requests", data.satellite_id, data.room_id, f"count={count}") tracker = self._get_tracker() if not tracker: return IntentResult.failure("ActivityTracker nicht verfuegbar.") intents = tracker.get_last_intents(int(count)) if not intents: return IntentResult.success_with_response("Keine Anfragen protokolliert.") lines = [] for entry in intents: intent_name = entry.get("intent", "?") text = entry.get("original_text", "")[:50] lines.append(f"{intent_name}: {text}") return IntentResult.success_with_response( f"Die letzten {len(intents)} Anfragen: " + ". ".join(lines) + "." ) @intent("admin_last_request_room", description="Letzte Anfrage im Raum") @admin_only async def handle_last_request_room(self, data: IntentReceivedData, room: str = "") -> IntentResult: """Gibt die letzte Anfrage in einem bestimmten Raum zurueck.""" _audit("admin_last_request_room", data.satellite_id, data.room_id, f"room={room}") if not room: return IntentResult.failure("Kein Raum angegeben.", "Bitte einen Raum angeben.") tracker = self._get_tracker() if not tracker: return IntentResult.failure("ActivityTracker nicht verfuegbar.") intents = tracker.get_last_intents_by_room(room, 1) if not intents: return IntentResult.success_with_response(f"Keine Anfragen im Raum {room} protokolliert.") last = intents[-1] text = last.get("original_text", "unbekannt") return IntentResult.success_with_response(f"Die letzte Anfrage im {room} war: {text}.") @intent("admin_last_conversation", description="Letzte Conversation anzeigen") @admin_only async def handle_last_conversation(self, data: IntentReceivedData) -> IntentResult: """Gibt Informationen zur letzten Conversation zurueck.""" _audit("admin_last_conversation", data.satellite_id, data.room_id) tracker = self._get_tracker() if not tracker: return IntentResult.failure("ActivityTracker nicht verfuegbar.") convs = tracker.get_last_conversations(1) if not convs: return IntentResult.success_with_response("Keine Conversations protokolliert.") conv = convs[-1] started = conv.get("started_at", "unbekannt") try: dt = datetime.fromisoformat(started) formatted = dt.strftime("%d.%m.%Y um %H:%M Uhr") except (ValueError, TypeError): formatted = started satellite = conv.get("satellite_id", "unbekannt") return IntentResult.success_with_response( f"Die letzte Conversation war am {formatted} mit Satellite {satellite}." ) @intent("admin_last_conversations", description="Letzte Conversations anzeigen") @admin_only async def handle_last_conversations(self, data: IntentReceivedData, count: int = 5) -> IntentResult: """Gibt die letzten N Conversations zurueck.""" _audit("admin_last_conversations", data.satellite_id, data.room_id, f"count={count}") tracker = self._get_tracker() if not tracker: return IntentResult.failure("ActivityTracker nicht verfuegbar.") convs = tracker.get_last_conversations(int(count)) if not convs: return IntentResult.success_with_response("Keine Conversations protokolliert.") lines = [] for conv in convs: started = conv.get("started_at", "?") try: dt = datetime.fromisoformat(started) formatted = dt.strftime("%d.%m. %H:%M") except (ValueError, TypeError): formatted = started satellite = conv.get("satellite_id", "?")[:8] lines.append(f"{formatted} ({satellite})") return IntentResult.success_with_response( f"Die letzten {len(convs)} Conversations: " + ", ".join(lines) + "." ) @intent("admin_last_errors", description="Letzte Fehler anzeigen") @admin_only async def handle_last_errors(self, data: IntentReceivedData, count: int = 5) -> IntentResult: """Gibt die letzten N Fehler zurueck.""" _audit("admin_last_errors", data.satellite_id, data.room_id, f"count={count}") tracker = self._get_tracker() if not tracker: return IntentResult.failure("ActivityTracker nicht verfuegbar.") errors = tracker.get_last_errors(int(count)) if not errors: return IntentResult.success_with_response("Keine Fehler protokolliert.") lines = [] for err in errors: source = err.get("source", "?") message = err.get("message", "?")[:60] lines.append(f"{source}: {message}") return IntentResult.success_with_response( f"Die letzten {len(errors)} Fehler: " + ". ".join(lines) + "." ) @intent("admin_error_count_hours", description="Fehleranzahl in Stunden") @admin_only async def handle_error_count_hours(self, data: IntentReceivedData, hours: int = 24) -> IntentResult: """Gibt die Fehleranzahl der letzten N Stunden zurueck.""" _audit("admin_error_count_hours", data.satellite_id, data.room_id, f"hours={hours}") tracker = self._get_tracker() if not tracker: return IntentResult.failure("ActivityTracker nicht verfuegbar.") count = tracker.get_error_count_since(int(hours)) return IntentResult.success_with_response( f"Es gab {count} Fehler in den letzten {hours} Stunden." ) @intent("admin_config_tool_last", description="Letzte Config-Tool Verbindung") @admin_only async def handle_config_tool_last(self, data: IntentReceivedData) -> IntentResult: """Gibt die letzte Config-Tool-Verbindung zurueck.""" _audit("admin_config_tool_last", data.satellite_id, data.room_id) tracker = self._get_tracker() if not tracker: return IntentResult.failure("ActivityTracker nicht verfuegbar.") last = tracker.get_last_config_connection() if not last: return IntentResult.success_with_response("Kein Config-Tool war bisher verbunden.") connected_at = last.get("connected_at", "unbekannt") peer = last.get("peer_address", "unbekannt") try: dt = datetime.fromisoformat(connected_at) formatted = dt.strftime("%d.%m.%Y um %H:%M Uhr") except (ValueError, TypeError): formatted = connected_at return IntentResult.success_with_response( f"Das letzte Config-Tool war am {formatted} von {peer} verbunden." ) @intent("admin_cache_clear", description="Activity-Cache leeren") @admin_only async def handle_cache_clear(self, data: IntentReceivedData) -> IntentResult: """Loescht alle Activity-Daten.""" _audit("admin_cache_clear", data.satellite_id, data.room_id) tracker = self._get_tracker() if not tracker: return IntentResult.failure("ActivityTracker nicht verfuegbar.") tracker.clear_all() return IntentResult.success_with_response("Cache wurde geleert.") # ========================================================================= # Hilfsmethoden # ========================================================================= @staticmethod def _get_primary_ip() -> str: """Ermittelt die primaere IP-Adresse.""" try: # Verbindung zu externem Host (ohne tatsaechlich zu senden) s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(0.1) s.connect(("8.8.8.8", 80)) ip = s.getsockname()[0] s.close() return ip except Exception: return socket.gethostbyname(socket.gethostname()) @staticmethod def _format_duration(seconds: float) -> str: """Formatiert eine Dauer in lesbaren Text.""" total = int(seconds) days = total // 86400 hours = (total % 86400) // 3600 minutes = (total % 3600) // 60 parts = [] if days > 0: parts.append(f"{days} {'Tag' if days == 1 else 'Tagen'}") if hours > 0: parts.append(f"{hours} {'Stunde' if hours == 1 else 'Stunden'}") if minutes > 0: parts.append(f"{minutes} {'Minute' if minutes == 1 else 'Minuten'}") if not parts: return "weniger als einer Minute" return ", ".join(parts) @staticmethod def _format_training_entry(entry: dict[str, Any]) -> str: """Formatiert einen Trainings-Eintrag als Text.""" trainer_id = entry.get("trainer_id", "unbekannt") started_at = entry.get("started_at", "unbekannt") success = entry.get("success", False) duration = entry.get("duration_seconds", 0) try: dt = datetime.fromisoformat(started_at) formatted = dt.strftime("%d.%m.%Y um %H:%M Uhr") except (ValueError, TypeError): formatted = started_at status = "erfolgreich" if success else "fehlgeschlagen" duration_text = AdminCommandsPlugin._format_duration(duration) if duration else "unbekannt" return ( f"Das letzte Training '{trainer_id}' lief am {formatted}, " f"Dauer: {duration_text}, Status: {status}." )