main.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. # -*- coding: utf-8 -*-
  2. """
  3. Calendar Cloud Plugin fuer Trixy.
  4. Ermoeglicht Kalender-Anbindung an Microsoft Outlook, Google Calendar
  5. und CalDAV/Nextcloud. Mitarbeiter koennen per Sprache Termine abfragen,
  6. suchen und erstellen. Bei Erinnerungen und Terminbeginn werden Sounds
  7. auf den zugehoerigen Satellites abgespielt.
  8. """
  9. from datetime import date, datetime, timedelta
  10. from pathlib import Path
  11. from trixy_core.plugins import TrixyPlugin
  12. from trixy_core.nlp.intent_registry import IntentRegistry
  13. from trixy_core.nlp.decorators import intent, pattern, example
  14. from trixy_core.nlp.handler import IntentReceivedData, IntentResult
  15. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  16. from plugins.calendar.models import CalendarEvent
  17. from plugins.calendar.providers.base import CalendarProvider
  18. from plugins.calendar.providers.microsoft import MicrosoftProvider, HTTPX_AVAILABLE
  19. from plugins.calendar.providers.google import GoogleProvider, GOOGLE_AUTH_AVAILABLE, GOOGLE_API_AVAILABLE
  20. from plugins.calendar.providers.caldav_provider import CalDAVProvider, CALDAV_AVAILABLE, ICALENDAR_AVAILABLE
  21. from plugins.calendar.mapping import SatelliteCalendarMapper
  22. from plugins.calendar.sync import CalendarSyncManager
  23. from plugins.calendar.notifications import NotificationManager
  24. from plugins.calendar import utils
  25. # Provider-Typ-Mapping
  26. PROVIDER_TYPES: dict[str, type] = {
  27. "microsoft": MicrosoftProvider,
  28. "google": GoogleProvider,
  29. "caldav": CalDAVProvider,
  30. }
  31. class CalendarPlugin(TrixyPlugin):
  32. """
  33. Calendar Cloud Plugin.
  34. Orchestriert Provider, Sync, Notifications und Intent-Handler.
  35. """
  36. NAME = "calendar"
  37. VERSION = "1.0.0"
  38. DESCRIPTION = "Kalender-Anbindung an Cloud-Dienste (Outlook, Google, CalDAV)"
  39. AUTHOR = "Trixy"
  40. def __init__(self, application, plugin_path, config=None):
  41. super().__init__(application, plugin_path, config)
  42. self._registry = IntentRegistry.get_instance()
  43. self._providers: dict[str, CalendarProvider] = {}
  44. self._mapper: SatelliteCalendarMapper | None = None
  45. self._sync_manager: CalendarSyncManager | None = None
  46. self._notification_manager: NotificationManager | None = None
  47. self._scheduler_job_id: str | None = None
  48. # =========================================================================
  49. # Lifecycle
  50. # =========================================================================
  51. async def on_load(self) -> None:
  52. """Initialisiert das Plugin."""
  53. pinfo(f"CalendarPlugin: Lade Plugin v{self.VERSION}")
  54. # 1. Provider initialisieren
  55. await self._init_providers()
  56. if not self._providers:
  57. pwarn("CalendarPlugin: Keine Provider verfuegbar — Plugin geladen ohne Cloud-Anbindung")
  58. # 2. Mapper erstellen
  59. self._mapper = SatelliteCalendarMapper(
  60. config=self._config,
  61. application=self.application,
  62. )
  63. # 3. Sync-Manager erstellen
  64. sync_config = self.get_config_value("sync", {})
  65. calendars_config = self.get_config_value("calendars", {})
  66. self._sync_manager = CalendarSyncManager(
  67. providers=self._providers,
  68. calendars_config=calendars_config,
  69. lookahead_hours=sync_config.get("lookahead_hours", 48),
  70. max_retries=sync_config.get("max_retries", 3),
  71. )
  72. # 4. Initialer Sync
  73. if self._providers:
  74. try:
  75. results = await self._sync_manager.sync_all()
  76. pdebug(f"CalendarPlugin: Initialer Sync: {results}")
  77. except Exception as e:
  78. perror(f"CalendarPlugin: Initialer Sync fehlgeschlagen: {e}")
  79. # 5. Notification-Manager erstellen und Sounds laden
  80. notify_config = self.get_config_value("notifications", {})
  81. self._notification_manager = NotificationManager(
  82. application=self.application,
  83. sync_manager=self._sync_manager,
  84. mapper=self._mapper,
  85. config=notify_config,
  86. )
  87. self._load_notification_sounds(notify_config)
  88. # 6. Scheduler-Job registrieren
  89. self._register_scheduler_job(sync_config)
  90. # 7. Notification-Check-Task starten
  91. await self._notification_manager.start_checking()
  92. # 8. Intents werden automatisch vom PluginManager registriert
  93. # (via @intent/@pattern/@example Dekoratoren auf den Handler-Methoden)
  94. pinfo(f"CalendarPlugin: Geladen ({len(self._providers)} Provider aktiv)")
  95. async def on_unload(self) -> None:
  96. """Entlaedt das Plugin."""
  97. # 1. Notification-Task stoppen
  98. if self._notification_manager:
  99. await self._notification_manager.stop_checking()
  100. # 2. Scheduler-Job entfernen
  101. self._remove_scheduler_job()
  102. # 3. Intents deregistrieren
  103. self._registry.unregister_plugin(self.NAME)
  104. # 4. Provider disconnect
  105. for name, provider in self._providers.items():
  106. try:
  107. await provider.disconnect()
  108. except Exception as e:
  109. perror(f"CalendarPlugin: Fehler beim Trennen von '{name}': {e}")
  110. self._providers.clear()
  111. pdebug("CalendarPlugin: Entladen")
  112. async def on_enable(self) -> None:
  113. """Aktiviert das Plugin."""
  114. pinfo("CalendarPlugin: Aktiviert")
  115. async def on_disable(self) -> None:
  116. """Deaktiviert das Plugin."""
  117. pinfo("CalendarPlugin: Deaktiviert")
  118. # =========================================================================
  119. # Provider-Initialisierung
  120. # =========================================================================
  121. async def _init_providers(self) -> None:
  122. """Initialisiert alle konfigurierten Provider."""
  123. providers_config = self.get_config_value("providers", {})
  124. for name, prov_config in providers_config.items():
  125. prov_type = prov_config.get("type", "")
  126. if prov_type not in PROVIDER_TYPES:
  127. pwarn(f"CalendarPlugin: Unbekannter Provider-Typ '{prov_type}' fuer '{name}'")
  128. continue
  129. # Verfuegbarkeit pruefen
  130. if prov_type == "microsoft" and not HTTPX_AVAILABLE:
  131. pwarn(f"CalendarPlugin: httpx nicht installiert, '{name}' uebersprungen")
  132. continue
  133. if prov_type == "google" and (not GOOGLE_AUTH_AVAILABLE or not GOOGLE_API_AVAILABLE):
  134. pwarn(f"CalendarPlugin: google-auth/google-api-python-client nicht installiert, '{name}' uebersprungen")
  135. continue
  136. if prov_type == "caldav" and (not CALDAV_AVAILABLE or not ICALENDAR_AVAILABLE):
  137. pwarn(f"CalendarPlugin: caldav/icalendar nicht installiert, '{name}' uebersprungen")
  138. continue
  139. # Credentials pruefen
  140. if not self._validate_provider_config(name, prov_type, prov_config):
  141. continue
  142. provider_class = PROVIDER_TYPES[prov_type]
  143. provider = provider_class(name=name, config=prov_config)
  144. try:
  145. connected = await provider.connect()
  146. if connected:
  147. self._providers[name] = provider
  148. pinfo(f"CalendarPlugin: Provider '{name}' ({prov_type}) verbunden")
  149. else:
  150. pwarn(f"CalendarPlugin: Provider '{name}' konnte nicht verbunden werden")
  151. except Exception as e:
  152. perror(f"CalendarPlugin: Fehler bei Provider '{name}': {e}")
  153. def _validate_provider_config(self, name: str, prov_type: str, config: dict) -> bool:
  154. """Validiert die Provider-Konfiguration."""
  155. if prov_type == "microsoft":
  156. if not config.get("tenant_id") or not config.get("client_id"):
  157. pwarn(f"CalendarPlugin: '{name}' — tenant_id und client_id erforderlich")
  158. return False
  159. auth_flow = config.get("auth_flow", "client_credentials")
  160. if auth_flow == "client_credentials" and not config.get("client_secret"):
  161. pwarn(f"CalendarPlugin: '{name}' — client_secret erforderlich fuer client_credentials")
  162. return False
  163. elif prov_type == "google":
  164. auth_flow = config.get("auth_flow", "service_account")
  165. if auth_flow == "service_account" and not config.get("service_account_file"):
  166. pwarn(f"CalendarPlugin: '{name}' — service_account_file erforderlich")
  167. return False
  168. elif prov_type == "caldav":
  169. if not config.get("url"):
  170. pwarn(f"CalendarPlugin: '{name}' — URL erforderlich")
  171. return False
  172. return True
  173. # =========================================================================
  174. # Sound-Laden
  175. # =========================================================================
  176. def _load_notification_sounds(self, notify_config: dict) -> None:
  177. """Laedt die Benachrichtigungs-Sounds."""
  178. asset_service = self.application.services.get_service("AssetService")
  179. if not asset_service:
  180. pwarn("CalendarPlugin: AssetService nicht verfuegbar")
  181. return
  182. reminder_pcm = self._load_sound(asset_service, notify_config.get("reminder_sound", "calender_remember.wav"))
  183. start_pcm = self._load_sound(asset_service, notify_config.get("start_sound", "calender.wav"))
  184. if self._notification_manager:
  185. self._notification_manager.set_sounds(reminder_pcm, start_pcm)
  186. def _load_sound(self, asset_service, sound_name: str) -> bytes | None:
  187. """Laedt einen Sound und gibt die rohen PCM-Daten zurueck."""
  188. audio_path: Path | None = asset_service.get_audio(sound_name)
  189. if not audio_path or not audio_path.exists():
  190. pwarn(f"CalendarPlugin: Sound nicht gefunden: {sound_name}")
  191. return None
  192. try:
  193. raw = audio_path.read_bytes()
  194. pcm = raw[44:] # WAV-Header (44 Bytes) abschneiden
  195. pdebug(f"CalendarPlugin: Sound geladen: {sound_name} ({len(pcm)} bytes PCM)")
  196. return pcm
  197. except Exception as e:
  198. perror(f"CalendarPlugin: Fehler beim Laden von {sound_name}: {e}")
  199. return None
  200. # =========================================================================
  201. # Scheduler
  202. # =========================================================================
  203. def _register_scheduler_job(self, sync_config: dict) -> None:
  204. """Registriert den periodischen Sync-Job."""
  205. scheduler = self.application.services.get_service("SchedulerService")
  206. if not scheduler:
  207. pwarn("CalendarPlugin: SchedulerService nicht verfuegbar, kein automatischer Sync")
  208. return
  209. from trixy_core.scheduler.trigger.interval import IntervalTrigger
  210. from trixy_core.scheduler.action.callback import CallbackAction
  211. interval_minutes = sync_config.get("interval_minutes", 30)
  212. try:
  213. job = scheduler.create_job(
  214. trigger=IntervalTrigger(minutes=interval_minutes),
  215. actions=CallbackAction(self._sync_manager.sync_all),
  216. job_id="calendar_sync",
  217. save=False,
  218. )
  219. self._scheduler_job_id = job.id if hasattr(job, "id") else "calendar_sync"
  220. pinfo(f"CalendarPlugin: Sync-Job registriert (alle {interval_minutes} Minuten)")
  221. except Exception as e:
  222. perror(f"CalendarPlugin: Scheduler-Job konnte nicht erstellt werden: {e}")
  223. def _remove_scheduler_job(self) -> None:
  224. """Entfernt den Sync-Job."""
  225. if not self._scheduler_job_id:
  226. return
  227. scheduler = self.application.services.get_service("SchedulerService")
  228. if scheduler:
  229. try:
  230. scheduler.remove_job(self._scheduler_job_id, delete_file=False)
  231. except Exception:
  232. pass
  233. self._scheduler_job_id = None
  234. # =========================================================================
  235. # Intent-Handler
  236. # =========================================================================
  237. @intent("next_appointment", description="Naechsten bevorstehenden Termin abfragen")
  238. @pattern("(wann|was) ist [mein] naechster termin")
  239. @pattern("naechster termin")
  240. @pattern("habe ich [bald] [einen] termin")
  241. @example("Wann ist mein naechster Termin?", "Naechster Termin")
  242. @example("Habe ich bald einen Termin?")
  243. async def handle_next_appointment(self, data: IntentReceivedData) -> IntentResult:
  244. """Gibt den naechsten Termin zurueck."""
  245. calendar_keys = self._get_calendar_keys(data.satellite_id)
  246. if not calendar_keys:
  247. return IntentResult.failure(
  248. "Keine Kalender konfiguriert",
  249. response_text="Fuer diesen Raum sind keine Kalender konfiguriert.",
  250. )
  251. event = self._sync_manager.get_next_event(calendar_keys)
  252. if not event:
  253. return IntentResult.success_with_response(
  254. "Du hast keine bevorstehenden Termine."
  255. )
  256. response = f"Dein naechster Termin ist '{event.title}' {utils.format_event_date(event)} {utils.format_event_time(event)}."
  257. return IntentResult.success_with_response(response)
  258. @intent("search_appointment", description="Termin anhand eines Suchbegriffs suchen")
  259. @pattern("wann ist [der] {query} [termin]")
  260. @pattern("um wieviel uhr ist {query}")
  261. @pattern("wann (habe ich|findet) [den|das] {query} [statt]")
  262. @example("Wann ist der Zahnarzt Termin?", "Um wieviel Uhr ist das Stand-Up?")
  263. @example("Wann findet das Meeting statt?")
  264. async def handle_search_appointment(self, data: IntentReceivedData, query: str = "") -> IntentResult:
  265. """Sucht einen Termin anhand eines Suchbegriffs."""
  266. query = data.get_slot("query", "")
  267. if not query:
  268. return IntentResult.failure(
  269. "Kein Suchbegriff",
  270. response_text="Was fuer einen Termin suchst du?",
  271. )
  272. calendar_keys = self._get_calendar_keys(data.satellite_id)
  273. if not calendar_keys:
  274. return IntentResult.failure(
  275. "Keine Kalender konfiguriert",
  276. response_text="Fuer diesen Raum sind keine Kalender konfiguriert.",
  277. )
  278. results = self._sync_manager.search_events(calendar_keys, query)
  279. if not results:
  280. return IntentResult.success_with_response(
  281. f"Ich habe keinen Termin zu '{query}' gefunden."
  282. )
  283. event = results[0]
  284. response = f"Der Termin '{event.title}' ist {utils.format_event_date(event)} {utils.format_event_time(event)}."
  285. if event.location:
  286. response += f" Ort: {event.location}."
  287. return IntentResult.success_with_response(response)
  288. @intent("appointment_attendees", description="Teilnehmer eines Termins abfragen")
  289. @pattern("wer ist [alles] (in|bei) [dem|dem] {query}")
  290. @pattern("wer nimmt [am|an] {query} teil")
  291. @pattern("welche teilnehmer hat {query}")
  292. @pattern("wer kommt [zum|zur] {query}")
  293. @example("Wer ist alles in dem Stand-Up?", "Wer nimmt am Meeting teil?")
  294. async def handle_appointment_attendees(self, data: IntentReceivedData, query: str = "") -> IntentResult:
  295. """Gibt die Teilnehmer eines Termins zurueck."""
  296. query = data.get_slot("query", "")
  297. if not query:
  298. return IntentResult.failure(
  299. "Kein Terminname",
  300. response_text="Fuer welchen Termin moechtest du die Teilnehmer wissen?",
  301. )
  302. calendar_keys = self._get_calendar_keys(data.satellite_id)
  303. if not calendar_keys:
  304. return IntentResult.failure(
  305. "Keine Kalender konfiguriert",
  306. response_text="Fuer diesen Raum sind keine Kalender konfiguriert.",
  307. )
  308. results = self._sync_manager.search_events(calendar_keys, query)
  309. if not results:
  310. return IntentResult.success_with_response(
  311. f"Ich habe keinen Termin zu '{query}' gefunden."
  312. )
  313. event = results[0]
  314. if not event.attendees:
  315. return IntentResult.success_with_response(
  316. f"Fuer '{event.title}' sind keine Teilnehmer eingetragen."
  317. )
  318. attendees_str = utils.format_attendees_for_tts(event.attendees)
  319. response = f"Im {event.title} sind: {attendees_str}."
  320. return IntentResult.success_with_response(response)
  321. @intent("check_person_schedule", description="Terminplan einer bestimmten Person abfragen")
  322. @pattern("wann hat {person} [seinen|ihren] [naechsten] termin")
  323. @pattern("wann hat {person} zeit")
  324. @pattern("hat {person} [heute] [noch] termine")
  325. @example("Wann hat André seinen naechsten Termin?", "Hat Max heute noch Termine?")
  326. async def handle_check_person_schedule(self, data: IntentReceivedData, person: str = "") -> IntentResult:
  327. """Gibt den Terminplan einer Person zurueck."""
  328. person = data.get_slot("person", "")
  329. if not person:
  330. return IntentResult.failure(
  331. "Kein Personenname",
  332. response_text="Fuer wen moechtest du die Termine abfragen?",
  333. )
  334. calendar_keys = self._mapper.find_person_calendars(person)
  335. if not calendar_keys:
  336. return IntentResult.failure(
  337. f"Keine Kalender fuer '{person}'",
  338. response_text=f"Ich habe keine Kalender fuer {person} gefunden.",
  339. )
  340. event = self._sync_manager.get_next_event(calendar_keys)
  341. if not event:
  342. return IntentResult.success_with_response(
  343. f"{person} hat keine bevorstehenden Termine."
  344. )
  345. response = f"{person} hat den naechsten Termin '{event.title}' {utils.format_event_date(event)} {utils.format_event_time(event)}."
  346. return IntentResult.success_with_response(response)
  347. @intent("create_appointment", description="Neuen Termin im Kalender erstellen")
  348. @pattern("(richte|trage|erstelle) [einen] [termin] {title} [um|fuer] {time} [ein]")
  349. @pattern("neuer termin {title} [am] {date} [um] {time}")
  350. @example("Richte einen Termin Zahnarzt um 15 Uhr ein")
  351. @example("Erstelle einen Termin Meeting um 14 Uhr fuer 30 Minuten")
  352. async def handle_create_appointment(
  353. self, data: IntentReceivedData,
  354. title: str = "", time: str = "", date: str = "", duration: int = 60,
  355. ) -> IntentResult:
  356. """Erstellt einen neuen Termin."""
  357. title = data.get_slot("title", "")
  358. time_str = data.get_slot("time", "")
  359. if not title:
  360. return IntentResult.failure(
  361. "Kein Titel",
  362. response_text="Wie soll der Termin heissen?",
  363. )
  364. if not time_str:
  365. return IntentResult.failure(
  366. "Keine Uhrzeit",
  367. response_text="Um wie viel Uhr soll der Termin sein?",
  368. )
  369. # Schreib-Kalender ermitteln
  370. cal_info = self._mapper.get_write_calendar(data.satellite_id)
  371. if not cal_info:
  372. return IntentResult.failure(
  373. "Kein beschreibbarer Kalender",
  374. response_text="Fuer diesen Raum ist kein beschreibbarer Kalender konfiguriert.",
  375. )
  376. # Provider holen
  377. provider = self._providers.get(cal_info.provider_name)
  378. if not provider or not provider.is_connected:
  379. return IntentResult.failure(
  380. "Provider nicht verfuegbar",
  381. response_text="Der Kalender-Dienst ist gerade nicht erreichbar.",
  382. )
  383. # Zeit parsen
  384. parsed_time = utils.parse_time_string(time_str)
  385. if not parsed_time:
  386. return IntentResult.failure(
  387. f"Uhrzeit nicht erkannt: {time_str}",
  388. response_text=f"Ich habe die Uhrzeit '{time_str}' nicht verstanden.",
  389. )
  390. # Datum parsen
  391. date_str = data.get_slot("date", "heute")
  392. parsed_date = utils.parse_date_string(date_str or "heute")
  393. if not parsed_date:
  394. parsed_date = date.today()
  395. # Dauer
  396. duration_minutes = data.get_slot("duration", 60)
  397. try:
  398. duration_minutes = int(duration_minutes)
  399. except (TypeError, ValueError):
  400. duration_minutes = 60
  401. # Datetime zusammenbauen
  402. hour, minute = parsed_time
  403. start_dt = datetime(parsed_date.year, parsed_date.month, parsed_date.day, hour, minute)
  404. end_dt = start_dt + timedelta(minutes=duration_minutes)
  405. # User-Email aus Kalender-Config aufloeser
  406. cal_key = self._get_write_calendar_key(data.satellite_id)
  407. cal_config = self._mapper.get_calendar_config(cal_key) if cal_key else {}
  408. user = cal_config.get("user", "")
  409. user_email = self._resolve_user_email(cal_info.provider_name, user)
  410. # Event erstellen
  411. event = await provider.create_event(
  412. calendar_id=cal_info.calendar_id,
  413. title=title,
  414. start=start_dt,
  415. end=end_dt,
  416. user=user_email,
  417. )
  418. if event:
  419. date_str_tts = utils.format_event_date(event)
  420. time_str_tts = utils.format_event_time(event)
  421. response = f"Termin '{title}' {date_str_tts} {time_str_tts} erstellt."
  422. return IntentResult.success_with_response(response)
  423. return IntentResult.failure(
  424. "Termin konnte nicht erstellt werden",
  425. response_text="Der Termin konnte leider nicht erstellt werden.",
  426. )
  427. @intent("list_appointments", description="Alle Termine fuer einen bestimmten Tag auflisten")
  428. @pattern("was steht [heute|morgen] an")
  429. @pattern("welche termine habe ich {date}")
  430. @pattern("termine [fuer] {date}")
  431. @pattern("was habe ich {date} [auf dem plan]")
  432. @pattern("zeig [meine] termine [fuer] {date}")
  433. @example("Was steht heute an?", "Welche Termine habe ich morgen?")
  434. @example("Termine fuer Freitag", "Zeig mir meine Termine fuer morgen")
  435. async def handle_list_appointments(self, data: IntentReceivedData, date: str = "") -> IntentResult:
  436. """Listet alle Termine fuer einen Tag auf."""
  437. date_str = data.get_slot("date", "heute")
  438. target_date = utils.parse_date_string(date_str or "heute")
  439. if not target_date:
  440. target_date = date.today()
  441. calendar_keys = self._get_calendar_keys(data.satellite_id)
  442. if not calendar_keys:
  443. return IntentResult.failure(
  444. "Keine Kalender konfiguriert",
  445. response_text="Fuer diesen Raum sind keine Kalender konfiguriert.",
  446. )
  447. day_start, day_end = utils.get_day_range(target_date)
  448. events = self._sync_manager.get_events(calendar_keys, start=day_start, end=day_end)
  449. if not events:
  450. date_label = utils.format_event_date(CalendarEvent(
  451. event_id="", calendar_id="", provider_name="", title="",
  452. start=datetime.combine(target_date, datetime.min.time()),
  453. end=datetime.combine(target_date, datetime.min.time()),
  454. ))
  455. return IntentResult.success_with_response(
  456. f"Du hast {date_label} keine Termine."
  457. )
  458. # Datum-Label
  459. date_label = utils.format_event_date(events[0])
  460. if len(events) == 1:
  461. response = f"Du hast {date_label} einen Termin. {utils.format_event_for_tts(events[0])}."
  462. else:
  463. response = f"Du hast {date_label} {len(events)} Termine. "
  464. parts = [utils.format_event_for_tts(e) for e in events]
  465. response += ", ".join(parts[:-1]) + " und " + parts[-1] + "."
  466. return IntentResult.success_with_response(response)
  467. # =========================================================================
  468. # Hilfsmethoden
  469. # =========================================================================
  470. def _get_calendar_keys(self, satellite_id: str) -> list[str]:
  471. """Gibt die Kalender-Keys fuer einen Satellite zurueck."""
  472. if not self._mapper:
  473. return []
  474. infos = self._mapper.get_calendars_for_satellite(satellite_id)
  475. return [
  476. key for key, cfg in self.get_config_value("calendars", {}).items()
  477. if any(
  478. i.calendar_id == cfg.get("calendar_id", "")
  479. and i.provider_name == cfg.get("provider", "")
  480. for i in infos
  481. )
  482. ]
  483. def _get_write_calendar_key(self, satellite_id: str) -> str:
  484. """Gibt den Kalender-Key des Schreib-Kalenders zurueck."""
  485. if not self._mapper:
  486. return ""
  487. # Aus dem Mapping-Eintrag holen
  488. entry = self._mapper._find_entry_for_satellite(satellite_id)
  489. if entry:
  490. return entry.get("default_write_calendar", "")
  491. return ""
  492. def _resolve_user_email(self, provider_name: str, user: str) -> str:
  493. """Loest einen User-Key in eine Email-Adresse auf."""
  494. if not user:
  495. return ""
  496. provider = self._providers.get(provider_name)
  497. if not provider:
  498. return user
  499. user_mapping = provider.config.get("user_mapping", {})
  500. return user_mapping.get(user, user)