main.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. # -*- coding: utf-8 -*-
  2. """
  3. AdminCommands Plugin.
  4. Stellt 27 Admin-Only Intents bereit fuer System-Info, Satellite-Verwaltung,
  5. Plugin-Status, Training-Steuerung und History/Log-Abfragen.
  6. Alle Intents erfordern das System-Command Wakeword.
  7. Patterns und Examples sind in intents_de.yml definiert (multilingual).
  8. """
  9. from __future__ import annotations
  10. import asyncio
  11. import logging
  12. import os
  13. import socket
  14. import time
  15. from datetime import datetime, timezone
  16. from pathlib import Path
  17. from typing import TYPE_CHECKING, Any
  18. from trixy_core.plugins.trixy_plugin import TrixyPlugin
  19. from trixy_core.nlp.decorators import intent, admin_only
  20. from trixy_core.nlp.handler import IntentResult, IntentReceivedData
  21. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  22. if TYPE_CHECKING:
  23. from trixy_core.activity.tracker import ActivityTracker
  24. # Audit-Logger fuer Admin-Befehle
  25. _audit_logger: logging.Logger | None = None
  26. def _get_audit_logger() -> logging.Logger:
  27. """Liefert den Audit-Logger (lazy init)."""
  28. global _audit_logger
  29. if _audit_logger is None:
  30. _audit_logger = logging.getLogger("admin_audit")
  31. _audit_logger.setLevel(logging.INFO)
  32. if not _audit_logger.handlers:
  33. log_dir = Path("logs")
  34. log_dir.mkdir(exist_ok=True)
  35. handler = logging.FileHandler(log_dir / "admin_audit.log", encoding="utf-8")
  36. handler.setFormatter(logging.Formatter(
  37. "%(asctime)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
  38. ))
  39. _audit_logger.addHandler(handler)
  40. return _audit_logger
  41. def _audit(intent_name: str, satellite_id: str, room_id: str, detail: str = "") -> None:
  42. """Schreibt einen Audit-Eintrag."""
  43. logger = _get_audit_logger()
  44. msg = f"ADMIN | intent={intent_name} | satellite={satellite_id} | room={room_id}"
  45. if detail:
  46. msg += f" | {detail}"
  47. logger.info(msg)
  48. class AdminCommandsPlugin(TrixyPlugin):
  49. """Admin-Befehle fuer System-Verwaltung per Sprache."""
  50. NAME = "admin_commands"
  51. VERSION = "1.0.0"
  52. DESCRIPTION = "Admin-Only Sprachbefehle fuer System, Satellites, Training und Logs"
  53. AUTHOR = "Trixy"
  54. def __init__(self, **kwargs) -> None:
  55. super().__init__(**kwargs)
  56. self._start_time: float = 0.0
  57. async def on_load(self) -> None:
  58. """Plugin geladen — Startzeit merken."""
  59. self._start_time = time.time()
  60. pinfo("[AdminCommands] Plugin geladen")
  61. async def on_unload(self) -> None:
  62. """Plugin entladen."""
  63. pdebug("[AdminCommands] Plugin entladen")
  64. def _get_tracker(self) -> "ActivityTracker | None":
  65. """Liefert den ActivityTracker Service."""
  66. try:
  67. return self.application.services.get_service("ActivityTracker")
  68. except Exception:
  69. return None
  70. # =========================================================================
  71. # System Info (1-6)
  72. # =========================================================================
  73. @intent("admin_hostname_query", description="Hostname abfragen")
  74. @admin_only
  75. async def handle_hostname_query(self, data: IntentReceivedData) -> IntentResult:
  76. """Gibt den Hostnamen zurueck."""
  77. _audit("admin_hostname_query", data.satellite_id, data.room_id)
  78. hostname = socket.gethostname()
  79. return IntentResult.success_with_response(f"Der Hostname ist {hostname}.")
  80. @intent("admin_domain_query", description="Domain abfragen")
  81. @admin_only
  82. async def handle_domain_query(self, data: IntentReceivedData) -> IntentResult:
  83. """Gibt den FQDN zurueck."""
  84. _audit("admin_domain_query", data.satellite_id, data.room_id)
  85. fqdn = socket.getfqdn()
  86. return IntentResult.success_with_response(f"Die Domain ist {fqdn}.")
  87. @intent("admin_ip_query", description="IP-Adresse abfragen")
  88. @admin_only
  89. async def handle_ip_query(self, data: IntentReceivedData) -> IntentResult:
  90. """Gibt die IP-Adresse zurueck."""
  91. _audit("admin_ip_query", data.satellite_id, data.room_id)
  92. ip = self._get_primary_ip()
  93. return IntentResult.success_with_response(f"Die IP-Adresse ist {ip}.")
  94. @intent("admin_uptime_query", description="Server-Laufzeit abfragen")
  95. @admin_only
  96. async def handle_uptime_query(self, data: IntentReceivedData) -> IntentResult:
  97. """Gibt die Server-Laufzeit zurueck."""
  98. _audit("admin_uptime_query", data.satellite_id, data.room_id)
  99. uptime_seconds = time.time() - self._start_time
  100. text = self._format_duration(uptime_seconds)
  101. return IntentResult.success_with_response(f"Der Server laeuft seit {text}.")
  102. @intent("admin_system_time_set", description="Systemuhrzeit setzen")
  103. @admin_only
  104. async def handle_system_time_set(self, data: IntentReceivedData, time_value: str = "") -> IntentResult:
  105. """Setzt die Systemuhrzeit."""
  106. _audit("admin_system_time_set", data.satellite_id, data.room_id, f"time={time_value}")
  107. if not time_value:
  108. return IntentResult.failure("Keine Uhrzeit angegeben.", "Bitte eine Uhrzeit angeben.")
  109. try:
  110. proc = await asyncio.create_subprocess_exec(
  111. "timedatectl", "set-time", time_value,
  112. stdout=asyncio.subprocess.PIPE,
  113. stderr=asyncio.subprocess.PIPE,
  114. )
  115. _, stderr = await proc.communicate()
  116. if proc.returncode != 0:
  117. err = stderr.decode().strip()
  118. return IntentResult.failure(err, f"Fehler beim Setzen der Uhrzeit: {err}")
  119. return IntentResult.success_with_response(f"Uhrzeit auf {time_value} gesetzt.")
  120. except Exception as e:
  121. return IntentResult.failure(str(e), f"Fehler: {e}")
  122. @intent("admin_system_date_set", description="Systemdatum setzen")
  123. @admin_only
  124. async def handle_system_date_set(self, data: IntentReceivedData, date_value: str = "") -> IntentResult:
  125. """Setzt das Systemdatum."""
  126. _audit("admin_system_date_set", data.satellite_id, data.room_id, f"date={date_value}")
  127. if not date_value:
  128. return IntentResult.failure("Kein Datum angegeben.", "Bitte ein Datum angeben.")
  129. try:
  130. proc = await asyncio.create_subprocess_exec(
  131. "timedatectl", "set-time", date_value,
  132. stdout=asyncio.subprocess.PIPE,
  133. stderr=asyncio.subprocess.PIPE,
  134. )
  135. _, stderr = await proc.communicate()
  136. if proc.returncode != 0:
  137. err = stderr.decode().strip()
  138. return IntentResult.failure(err, f"Fehler beim Setzen des Datums: {err}")
  139. return IntentResult.success_with_response(f"Datum auf {date_value} gesetzt.")
  140. except Exception as e:
  141. return IntentResult.failure(str(e), f"Fehler: {e}")
  142. # =========================================================================
  143. # Satellite Management (7-13)
  144. # =========================================================================
  145. @intent("admin_registration_enable", description="Registrierungsmodus aktivieren")
  146. @admin_only
  147. async def handle_registration_enable(self, data: IntentReceivedData) -> IntentResult:
  148. """Aktiviert den Registrierungsmodus."""
  149. _audit("admin_registration_enable", data.satellite_id, data.room_id)
  150. timeout = self.get_config_value("registration_timeout_seconds", 60)
  151. try:
  152. self.application.registration.enable_registration_mode(timeout)
  153. return IntentResult.success_with_response(
  154. f"Registrierungsmodus aktiviert fuer {timeout} Sekunden."
  155. )
  156. except Exception as e:
  157. return IntentResult.failure(str(e), f"Fehler: {e}")
  158. @intent("admin_registration_disable", description="Registrierungsmodus deaktivieren")
  159. @admin_only
  160. async def handle_registration_disable(self, data: IntentReceivedData) -> IntentResult:
  161. """Deaktiviert den Registrierungsmodus."""
  162. _audit("admin_registration_disable", data.satellite_id, data.room_id)
  163. try:
  164. self.application.registration.disable_registration_mode()
  165. return IntentResult.success_with_response("Registrierungsmodus deaktiviert.")
  166. except Exception as e:
  167. return IntentResult.failure(str(e), f"Fehler: {e}")
  168. @intent("admin_registration_status", description="Registrierungsmodus Status")
  169. @admin_only
  170. async def handle_registration_status(self, data: IntentReceivedData) -> IntentResult:
  171. """Gibt den Status des Registrierungsmodus zurueck."""
  172. _audit("admin_registration_status", data.satellite_id, data.room_id)
  173. try:
  174. active = self.application.registration.registration_mode
  175. if active:
  176. return IntentResult.success_with_response("Ja, der Registrierungsmodus ist aktiv.")
  177. return IntentResult.success_with_response("Nein, der Registrierungsmodus ist nicht aktiv.")
  178. except Exception as e:
  179. return IntentResult.failure(str(e), f"Fehler: {e}")
  180. @intent("admin_satellite_count_registered", description="Anzahl registrierter Satellites")
  181. @admin_only
  182. async def handle_satellite_count_registered(self, data: IntentReceivedData) -> IntentResult:
  183. """Gibt die Anzahl registrierter Satellites zurueck."""
  184. _audit("admin_satellite_count_registered", data.satellite_id, data.room_id)
  185. try:
  186. count = self.application.satellites.count
  187. return IntentResult.success_with_response(
  188. f"Es {'ist' if count == 1 else 'sind'} {count} Satellite{'s' if count != 1 else ''} registriert."
  189. )
  190. except Exception as e:
  191. return IntentResult.failure(str(e), f"Fehler: {e}")
  192. @intent("admin_satellite_count_connected", description="Anzahl verbundener Satellites")
  193. @admin_only
  194. async def handle_satellite_count_connected(self, data: IntentReceivedData) -> IntentResult:
  195. """Gibt die Anzahl verbundener Satellites zurueck."""
  196. _audit("admin_satellite_count_connected", data.satellite_id, data.room_id)
  197. try:
  198. count = self.application.satellites.connected_count
  199. return IntentResult.success_with_response(
  200. f"Es {'ist' if count == 1 else 'sind'} {count} Satellite{'s' if count != 1 else ''} verbunden."
  201. )
  202. except Exception as e:
  203. return IntentResult.failure(str(e), f"Fehler: {e}")
  204. @intent("admin_satellite_last_connected", description="Letzte Verbindung eines Satellites")
  205. @admin_only
  206. async def handle_satellite_last_connected(self, data: IntentReceivedData, alias: str = "") -> IntentResult:
  207. """Gibt die letzte Verbindungszeit eines Satellites zurueck."""
  208. _audit("admin_satellite_last_connected", data.satellite_id, data.room_id, f"alias={alias}")
  209. if not alias:
  210. return IntentResult.failure("Kein Alias angegeben.", "Bitte einen Satellite-Namen angeben.")
  211. tracker = self._get_tracker()
  212. if not tracker:
  213. return IntentResult.failure("ActivityTracker nicht verfuegbar.")
  214. history = tracker.get_satellite_connection_history(alias)
  215. if not history:
  216. return IntentResult.success_with_response(f"Keine Verbindungshistorie fuer {alias} gefunden.")
  217. last = history[-1]
  218. connected_at = last.get("connected_at", "unbekannt")
  219. try:
  220. dt = datetime.fromisoformat(connected_at)
  221. formatted = dt.strftime("%d.%m.%Y um %H:%M Uhr")
  222. except (ValueError, TypeError):
  223. formatted = connected_at
  224. return IntentResult.success_with_response(f"{alias} war zuletzt am {formatted} verbunden.")
  225. @intent("admin_satellite_unregister", description="Satellite-Registrierung loeschen")
  226. @admin_only
  227. async def handle_satellite_unregister(self, data: IntentReceivedData, alias: str = "") -> IntentResult:
  228. """Loescht die Registrierung eines Satellites."""
  229. _audit("admin_satellite_unregister", data.satellite_id, data.room_id, f"alias={alias}")
  230. if not alias:
  231. return IntentResult.failure("Kein Alias angegeben.", "Bitte einen Satellite-Namen angeben.")
  232. try:
  233. # Satellite nach Alias suchen
  234. satellite = None
  235. for s in self.application.satellites.get_all():
  236. if s.alias.lower() == alias.lower():
  237. satellite = s
  238. break
  239. if not satellite:
  240. return IntentResult.success_with_response(f"Kein Satellite mit dem Namen {alias} gefunden.")
  241. # Registrierung loeschen
  242. if self.application.registration.delete_registration(satellite.mac_address):
  243. self.application.satellites.remove(satellite.id)
  244. return IntentResult.success_with_response(f"Registrierung von {alias} wurde geloescht.")
  245. return IntentResult.failure("Loeschen fehlgeschlagen.", f"Registrierung von {alias} konnte nicht geloescht werden.")
  246. except Exception as e:
  247. return IntentResult.failure(str(e), f"Fehler: {e}")
  248. # =========================================================================
  249. # Plugin Management (14)
  250. # =========================================================================
  251. @intent("admin_plugin_count", description="Anzahl aktiver Plugins")
  252. @admin_only
  253. async def handle_plugin_count(self, data: IntentReceivedData) -> IntentResult:
  254. """Gibt die Anzahl geladener/aktiver Plugins zurueck."""
  255. _audit("admin_plugin_count", data.satellite_id, data.room_id)
  256. try:
  257. total = self.application.plugins.count
  258. enabled = self.application.plugins.enabled_count
  259. return IntentResult.success_with_response(
  260. f"Es sind {total} Plugins geladen, davon {enabled} aktiv."
  261. )
  262. except Exception as e:
  263. return IntentResult.failure(str(e), f"Fehler: {e}")
  264. # =========================================================================
  265. # Training (15-19)
  266. # =========================================================================
  267. @intent("admin_training_status", description="Trainings-Status abfragen")
  268. @admin_only
  269. async def handle_training_status(self, data: IntentReceivedData) -> IntentResult:
  270. """Gibt den aktuellen Trainings-Status zurueck."""
  271. _audit("admin_training_status", data.satellite_id, data.room_id)
  272. try:
  273. trainer_service = self.application.services.get_service("TrainerService")
  274. active = trainer_service.active_trainer_id
  275. if active:
  276. progress = trainer_service.current_progress
  277. return IntentResult.success_with_response(
  278. f"Ja, Training '{active}' laeuft. Status: {progress.state.name}."
  279. )
  280. return IntentResult.success_with_response("Nein, es laeuft kein Training.")
  281. except Exception as e:
  282. return IntentResult.failure(str(e), f"Fehler: {e}")
  283. @intent("admin_training_start", description="Training starten")
  284. @admin_only
  285. async def handle_training_start(self, data: IntentReceivedData, model_type: str = "") -> IntentResult:
  286. """Startet ein Training."""
  287. _audit("admin_training_start", data.satellite_id, data.room_id, f"model_type={model_type}")
  288. if not model_type:
  289. return IntentResult.failure("Kein Modelltyp angegeben.", "Bitte einen Modelltyp angeben.")
  290. try:
  291. trainer_service = self.application.services.get_service("TrainerService")
  292. success, error = await trainer_service.start_training(model_type)
  293. if success:
  294. return IntentResult.success_with_response(f"Training '{model_type}' wurde gestartet.")
  295. return IntentResult.failure(error, f"Training konnte nicht gestartet werden: {error}")
  296. except Exception as e:
  297. return IntentResult.failure(str(e), f"Fehler: {e}")
  298. @intent("admin_training_stop", description="Training stoppen")
  299. @admin_only
  300. async def handle_training_stop(self, data: IntentReceivedData) -> IntentResult:
  301. """Stoppt das laufende Training."""
  302. _audit("admin_training_stop", data.satellite_id, data.room_id)
  303. try:
  304. trainer_service = self.application.services.get_service("TrainerService")
  305. success, error = await trainer_service.stop_training()
  306. if success:
  307. return IntentResult.success_with_response("Training wurde gestoppt.")
  308. return IntentResult.failure(error, f"Training konnte nicht gestoppt werden: {error}")
  309. except Exception as e:
  310. return IntentResult.failure(str(e), f"Fehler: {e}")
  311. @intent("admin_training_last", description="Letztes Training abfragen")
  312. @admin_only
  313. async def handle_training_last(self, data: IntentReceivedData) -> IntentResult:
  314. """Gibt Informationen zum letzten Training zurueck."""
  315. _audit("admin_training_last", data.satellite_id, data.room_id)
  316. tracker = self._get_tracker()
  317. if not tracker:
  318. return IntentResult.failure("ActivityTracker nicht verfuegbar.")
  319. last = tracker.get_last_training()
  320. if not last:
  321. return IntentResult.success_with_response("Es wurde noch kein Training durchgefuehrt.")
  322. return IntentResult.success_with_response(self._format_training_entry(last))
  323. @intent("admin_training_last_specific", description="Letztes Training nach Typ abfragen")
  324. @admin_only
  325. async def handle_training_last_specific(self, data: IntentReceivedData, model_type: str = "") -> IntentResult:
  326. """Gibt Informationen zum letzten Training eines bestimmten Typs zurueck."""
  327. _audit("admin_training_last_specific", data.satellite_id, data.room_id, f"model_type={model_type}")
  328. if not model_type:
  329. return IntentResult.failure("Kein Modelltyp angegeben.", "Bitte einen Modelltyp angeben.")
  330. tracker = self._get_tracker()
  331. if not tracker:
  332. return IntentResult.failure("ActivityTracker nicht verfuegbar.")
  333. last = tracker.get_last_training(model_type)
  334. if not last:
  335. return IntentResult.success_with_response(f"Es wurde noch kein '{model_type}' Training durchgefuehrt.")
  336. return IntentResult.success_with_response(self._format_training_entry(last))
  337. # =========================================================================
  338. # History / Logs (20-27)
  339. # =========================================================================
  340. @intent("admin_last_requests", description="Letzte Anfragen anzeigen")
  341. @admin_only
  342. async def handle_last_requests(self, data: IntentReceivedData, count: int = 5) -> IntentResult:
  343. """Gibt die letzten N Anfragen zurueck."""
  344. _audit("admin_last_requests", data.satellite_id, data.room_id, f"count={count}")
  345. tracker = self._get_tracker()
  346. if not tracker:
  347. return IntentResult.failure("ActivityTracker nicht verfuegbar.")
  348. intents = tracker.get_last_intents(int(count))
  349. if not intents:
  350. return IntentResult.success_with_response("Keine Anfragen protokolliert.")
  351. lines = []
  352. for entry in intents:
  353. intent_name = entry.get("intent", "?")
  354. text = entry.get("original_text", "")[:50]
  355. lines.append(f"{intent_name}: {text}")
  356. return IntentResult.success_with_response(
  357. f"Die letzten {len(intents)} Anfragen: " + ". ".join(lines) + "."
  358. )
  359. @intent("admin_last_request_room", description="Letzte Anfrage im Raum")
  360. @admin_only
  361. async def handle_last_request_room(self, data: IntentReceivedData, room: str = "") -> IntentResult:
  362. """Gibt die letzte Anfrage in einem bestimmten Raum zurueck."""
  363. _audit("admin_last_request_room", data.satellite_id, data.room_id, f"room={room}")
  364. if not room:
  365. return IntentResult.failure("Kein Raum angegeben.", "Bitte einen Raum angeben.")
  366. tracker = self._get_tracker()
  367. if not tracker:
  368. return IntentResult.failure("ActivityTracker nicht verfuegbar.")
  369. intents = tracker.get_last_intents_by_room(room, 1)
  370. if not intents:
  371. return IntentResult.success_with_response(f"Keine Anfragen im Raum {room} protokolliert.")
  372. last = intents[-1]
  373. text = last.get("original_text", "unbekannt")
  374. return IntentResult.success_with_response(f"Die letzte Anfrage im {room} war: {text}.")
  375. @intent("admin_last_conversation", description="Letzte Conversation anzeigen")
  376. @admin_only
  377. async def handle_last_conversation(self, data: IntentReceivedData) -> IntentResult:
  378. """Gibt Informationen zur letzten Conversation zurueck."""
  379. _audit("admin_last_conversation", data.satellite_id, data.room_id)
  380. tracker = self._get_tracker()
  381. if not tracker:
  382. return IntentResult.failure("ActivityTracker nicht verfuegbar.")
  383. convs = tracker.get_last_conversations(1)
  384. if not convs:
  385. return IntentResult.success_with_response("Keine Conversations protokolliert.")
  386. conv = convs[-1]
  387. started = conv.get("started_at", "unbekannt")
  388. try:
  389. dt = datetime.fromisoformat(started)
  390. formatted = dt.strftime("%d.%m.%Y um %H:%M Uhr")
  391. except (ValueError, TypeError):
  392. formatted = started
  393. satellite = conv.get("satellite_id", "unbekannt")
  394. return IntentResult.success_with_response(
  395. f"Die letzte Conversation war am {formatted} mit Satellite {satellite}."
  396. )
  397. @intent("admin_last_conversations", description="Letzte Conversations anzeigen")
  398. @admin_only
  399. async def handle_last_conversations(self, data: IntentReceivedData, count: int = 5) -> IntentResult:
  400. """Gibt die letzten N Conversations zurueck."""
  401. _audit("admin_last_conversations", data.satellite_id, data.room_id, f"count={count}")
  402. tracker = self._get_tracker()
  403. if not tracker:
  404. return IntentResult.failure("ActivityTracker nicht verfuegbar.")
  405. convs = tracker.get_last_conversations(int(count))
  406. if not convs:
  407. return IntentResult.success_with_response("Keine Conversations protokolliert.")
  408. lines = []
  409. for conv in convs:
  410. started = conv.get("started_at", "?")
  411. try:
  412. dt = datetime.fromisoformat(started)
  413. formatted = dt.strftime("%d.%m. %H:%M")
  414. except (ValueError, TypeError):
  415. formatted = started
  416. satellite = conv.get("satellite_id", "?")[:8]
  417. lines.append(f"{formatted} ({satellite})")
  418. return IntentResult.success_with_response(
  419. f"Die letzten {len(convs)} Conversations: " + ", ".join(lines) + "."
  420. )
  421. @intent("admin_last_errors", description="Letzte Fehler anzeigen")
  422. @admin_only
  423. async def handle_last_errors(self, data: IntentReceivedData, count: int = 5) -> IntentResult:
  424. """Gibt die letzten N Fehler zurueck."""
  425. _audit("admin_last_errors", data.satellite_id, data.room_id, f"count={count}")
  426. tracker = self._get_tracker()
  427. if not tracker:
  428. return IntentResult.failure("ActivityTracker nicht verfuegbar.")
  429. errors = tracker.get_last_errors(int(count))
  430. if not errors:
  431. return IntentResult.success_with_response("Keine Fehler protokolliert.")
  432. lines = []
  433. for err in errors:
  434. source = err.get("source", "?")
  435. message = err.get("message", "?")[:60]
  436. lines.append(f"{source}: {message}")
  437. return IntentResult.success_with_response(
  438. f"Die letzten {len(errors)} Fehler: " + ". ".join(lines) + "."
  439. )
  440. @intent("admin_error_count_hours", description="Fehleranzahl in Stunden")
  441. @admin_only
  442. async def handle_error_count_hours(self, data: IntentReceivedData, hours: int = 24) -> IntentResult:
  443. """Gibt die Fehleranzahl der letzten N Stunden zurueck."""
  444. _audit("admin_error_count_hours", data.satellite_id, data.room_id, f"hours={hours}")
  445. tracker = self._get_tracker()
  446. if not tracker:
  447. return IntentResult.failure("ActivityTracker nicht verfuegbar.")
  448. count = tracker.get_error_count_since(int(hours))
  449. return IntentResult.success_with_response(
  450. f"Es gab {count} Fehler in den letzten {hours} Stunden."
  451. )
  452. @intent("admin_config_tool_last", description="Letzte Config-Tool Verbindung")
  453. @admin_only
  454. async def handle_config_tool_last(self, data: IntentReceivedData) -> IntentResult:
  455. """Gibt die letzte Config-Tool-Verbindung zurueck."""
  456. _audit("admin_config_tool_last", data.satellite_id, data.room_id)
  457. tracker = self._get_tracker()
  458. if not tracker:
  459. return IntentResult.failure("ActivityTracker nicht verfuegbar.")
  460. last = tracker.get_last_config_connection()
  461. if not last:
  462. return IntentResult.success_with_response("Kein Config-Tool war bisher verbunden.")
  463. connected_at = last.get("connected_at", "unbekannt")
  464. peer = last.get("peer_address", "unbekannt")
  465. try:
  466. dt = datetime.fromisoformat(connected_at)
  467. formatted = dt.strftime("%d.%m.%Y um %H:%M Uhr")
  468. except (ValueError, TypeError):
  469. formatted = connected_at
  470. return IntentResult.success_with_response(
  471. f"Das letzte Config-Tool war am {formatted} von {peer} verbunden."
  472. )
  473. @intent("admin_cache_clear", description="Activity-Cache leeren")
  474. @admin_only
  475. async def handle_cache_clear(self, data: IntentReceivedData) -> IntentResult:
  476. """Loescht alle Activity-Daten."""
  477. _audit("admin_cache_clear", data.satellite_id, data.room_id)
  478. tracker = self._get_tracker()
  479. if not tracker:
  480. return IntentResult.failure("ActivityTracker nicht verfuegbar.")
  481. tracker.clear_all()
  482. return IntentResult.success_with_response("Cache wurde geleert.")
  483. # =========================================================================
  484. # Hilfsmethoden
  485. # =========================================================================
  486. @staticmethod
  487. def _get_primary_ip() -> str:
  488. """Ermittelt die primaere IP-Adresse."""
  489. try:
  490. # Verbindung zu externem Host (ohne tatsaechlich zu senden)
  491. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  492. s.settimeout(0.1)
  493. s.connect(("8.8.8.8", 80))
  494. ip = s.getsockname()[0]
  495. s.close()
  496. return ip
  497. except Exception:
  498. return socket.gethostbyname(socket.gethostname())
  499. @staticmethod
  500. def _format_duration(seconds: float) -> str:
  501. """Formatiert eine Dauer in lesbaren Text."""
  502. total = int(seconds)
  503. days = total // 86400
  504. hours = (total % 86400) // 3600
  505. minutes = (total % 3600) // 60
  506. parts = []
  507. if days > 0:
  508. parts.append(f"{days} {'Tag' if days == 1 else 'Tagen'}")
  509. if hours > 0:
  510. parts.append(f"{hours} {'Stunde' if hours == 1 else 'Stunden'}")
  511. if minutes > 0:
  512. parts.append(f"{minutes} {'Minute' if minutes == 1 else 'Minuten'}")
  513. if not parts:
  514. return "weniger als einer Minute"
  515. return ", ".join(parts)
  516. @staticmethod
  517. def _format_training_entry(entry: dict[str, Any]) -> str:
  518. """Formatiert einen Trainings-Eintrag als Text."""
  519. trainer_id = entry.get("trainer_id", "unbekannt")
  520. started_at = entry.get("started_at", "unbekannt")
  521. success = entry.get("success", False)
  522. duration = entry.get("duration_seconds", 0)
  523. try:
  524. dt = datetime.fromisoformat(started_at)
  525. formatted = dt.strftime("%d.%m.%Y um %H:%M Uhr")
  526. except (ValueError, TypeError):
  527. formatted = started_at
  528. status = "erfolgreich" if success else "fehlgeschlagen"
  529. duration_text = AdminCommandsPlugin._format_duration(duration) if duration else "unbekannt"
  530. return (
  531. f"Das letzte Training '{trainer_id}' lief am {formatted}, "
  532. f"Dauer: {duration_text}, Status: {status}."
  533. )