main.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  1. # -*- coding: utf-8 -*-
  2. """
  3. Reminders-Plugin fuer Trixy.
  4. Ermoeglicht das Erstellen, Auflisten und Loeschen von Erinnerungen
  5. per Sprache. Unterstuetzt Multi-Turn-Dialoge mit Rueckfragen
  6. (Was? → Wann? → VM/NM?).
  7. Bei Faelligkeit wird eine TTS-Meldung abgespielt.
  8. Nutzt den SyncStore fuer persistente Speicherung und
  9. automatische Synchronisation mit dem Server.
  10. """
  11. import asyncio
  12. from datetime import datetime, timedelta
  13. from pathlib import Path
  14. from typing import Any
  15. from trixy_core.plugins import TrixyPlugin
  16. from trixy_core.sync.models import SyncItem, _now_iso
  17. from trixy_core.sync.store import SyncStore
  18. from trixy_core.nlp.intent_registry import IntentRegistry
  19. from trixy_core.nlp.decorators import intent, pattern, example
  20. from trixy_core.nlp.handler import IntentReceivedData, IntentResult
  21. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  22. from plugins.reminders.time_parser import parse_german_time, ParsedTime
  23. DATA_TYPE = "reminders.reminder"
  24. class RemindersPlugin(TrixyPlugin):
  25. """
  26. Erinnerungen-Plugin mit Multi-Turn-Dialog.
  27. Bietet:
  28. - Erinnerungen erstellen (mit Rueckfragen: Was? Wann? VM/NM?)
  29. - Erinnerungen mit allen Details in einem Satz
  30. - Alle Erinnerungen auflisten
  31. - Heutige Erinnerungen anzeigen
  32. - Erinnerungen loeschen (einzeln und alle)
  33. - Timer-Loop prueft alle 60s auf faellige Erinnerungen
  34. Nutzt SyncStore fuer Persistenz und Sync.
  35. """
  36. NAME = "reminders"
  37. VERSION = "1.0.0"
  38. DESCRIPTION = "Erinnerungen per Sprachbefehl erstellen und verwalten"
  39. AUTHOR = "Trixy"
  40. def __init__(self, application: Any, plugin_path: Any, config: dict[str, Any] | None = None) -> None:
  41. super().__init__(application, plugin_path, config)
  42. self._registry = IntentRegistry.get_instance()
  43. self._store: SyncStore | None = None
  44. # Dialog-State fuer Multi-Turn (session_id → state)
  45. self._dialog_state: dict[str, dict[str, Any]] = {}
  46. # Pending delete-all Rueckfragen
  47. self._pending_delete_all: dict[str, bool] = {}
  48. # Hintergrund-Task fuer Erinnerungs-Pruefung
  49. self._check_task: asyncio.Task | None = None
  50. # Notification-Sound (PCM)
  51. self._notification_pcm: bytes | None = None
  52. # =========================================================================
  53. # Lifecycle
  54. # =========================================================================
  55. async def on_load(self) -> None:
  56. """Initialisiert das Plugin."""
  57. pinfo(f"RemindersPlugin: Lade Plugin v{self.VERSION}")
  58. # SyncStore holen
  59. self._store = self.application.sync_store
  60. if self._store is None:
  61. self._store = SyncStore(base_directory="./sync_data")
  62. pdebug("RemindersPlugin: Eigenen SyncStore erstellt (kein globaler verfuegbar)")
  63. # Notification-Sound vorladen
  64. self._load_notification_sound()
  65. # Hintergrund-Task starten fuer faellige Erinnerungen
  66. self._check_task = asyncio.create_task(self._check_reminders_loop())
  67. pinfo("RemindersPlugin: Geladen")
  68. async def on_unload(self) -> None:
  69. """Entlaedt das Plugin."""
  70. # Hintergrund-Task stoppen
  71. if self._check_task and not self._check_task.done():
  72. self._check_task.cancel()
  73. try:
  74. await self._check_task
  75. except asyncio.CancelledError:
  76. pass
  77. self._registry.unregister_plugin(self.NAME)
  78. self._dialog_state.clear()
  79. self._pending_delete_all.clear()
  80. self._notification_pcm = None
  81. pdebug("RemindersPlugin: Entladen")
  82. # =========================================================================
  83. # Notification-Sound
  84. # =========================================================================
  85. def _load_notification_sound(self) -> None:
  86. """Laedt den Notification-Sound und entfernt den WAV-Header."""
  87. sound_name = self.get_config_value("notification_sound", "notification.wav")
  88. asset_service = self.application.services.get_service("AssetService")
  89. if not asset_service:
  90. return
  91. audio_path: Path | None = asset_service.get_audio(sound_name)
  92. if not audio_path or not audio_path.exists():
  93. return
  94. try:
  95. raw = audio_path.read_bytes()
  96. self._notification_pcm = raw[44:]
  97. pdebug(f"RemindersPlugin: Notification-Sound geladen ({len(self._notification_pcm)} bytes)")
  98. except Exception as e:
  99. perror(f"RemindersPlugin: Notification-Sound Fehler: {e}")
  100. # =========================================================================
  101. # Hintergrund-Pruefung faelliger Erinnerungen
  102. # =========================================================================
  103. async def _check_reminders_loop(self) -> None:
  104. """Prueft alle 60 Sekunden ob eine Erinnerung faellig ist."""
  105. interval = self.get_config_value("check_interval_seconds", 60)
  106. while True:
  107. try:
  108. await asyncio.sleep(interval)
  109. await self._check_due_reminders()
  110. except asyncio.CancelledError:
  111. break
  112. except Exception as e:
  113. perror(f"RemindersPlugin: Fehler in Check-Loop: {e}")
  114. async def _check_due_reminders(self) -> None:
  115. """Prueft und benachrichtigt faellige Erinnerungen."""
  116. if not self._store:
  117. return
  118. now = datetime.now()
  119. reminders = self._store.get_all(DATA_TYPE)
  120. for reminder in reminders:
  121. if reminder.data.get("notified") or reminder.data.get("completed"):
  122. continue
  123. remind_at_str = reminder.data.get("remind_at", "")
  124. if not remind_at_str:
  125. continue
  126. try:
  127. remind_at = datetime.fromisoformat(remind_at_str)
  128. # Timezone-naive Vergleich
  129. if remind_at.tzinfo:
  130. remind_at = remind_at.replace(tzinfo=None)
  131. except (ValueError, TypeError):
  132. continue
  133. if remind_at <= now:
  134. await self._trigger_reminder(reminder)
  135. async def _trigger_reminder(self, reminder: SyncItem) -> None:
  136. """Benachrichtigt ueber eine faellige Erinnerung."""
  137. title = reminder.data.get("title", "Erinnerung")
  138. pinfo(f"RemindersPlugin: Erinnerung faellig: {title}")
  139. # Als benachrichtigt markieren
  140. reminder.data["notified"] = True
  141. reminder.data["completed"] = True
  142. reminder.updated_at = _now_iso()
  143. reminder.version += 1
  144. self._store.save(reminder)
  145. # TTS-Meldung via Event (funktioniert im Server- und Standalone-Modus)
  146. text = f"Erinnerung: {title}."
  147. await self.application.events.emit("tts_request", {
  148. "request_id": f"reminder-{reminder.item_id}",
  149. "satellite_id": reminder.source_id or "",
  150. "text": text,
  151. })
  152. # =========================================================================
  153. # Dialog-State Verwaltung
  154. # =========================================================================
  155. def _get_dialog_step(self, session_id: str) -> str:
  156. """Gibt den aktuellen Dialog-Schritt zurueck."""
  157. state = self._dialog_state.get(session_id)
  158. if state:
  159. return state.get("step", "")
  160. return ""
  161. def _set_dialog_step(self, session_id: str, step: str) -> None:
  162. """Setzt den Dialog-Schritt."""
  163. if session_id not in self._dialog_state:
  164. self._dialog_state[session_id] = {}
  165. self._dialog_state[session_id]["step"] = step
  166. def _set_dialog_data(self, session_id: str, key: str, value: Any) -> None:
  167. """Speichert einen Wert im Dialog-State."""
  168. if session_id not in self._dialog_state:
  169. self._dialog_state[session_id] = {}
  170. self._dialog_state[session_id][key] = value
  171. def _get_dialog_data(self, session_id: str, key: str, default: Any = None) -> Any:
  172. """Liest einen Wert aus dem Dialog-State."""
  173. state = self._dialog_state.get(session_id)
  174. if state:
  175. return state.get(key, default)
  176. return default
  177. def _clear_dialog(self, session_id: str) -> None:
  178. """Loescht den Dialog-State fuer eine Session."""
  179. self._dialog_state.pop(session_id, None)
  180. # =========================================================================
  181. # Intent-Handler: Erinnerung erstellen (Multi-Turn)
  182. # =========================================================================
  183. @intent("create_reminder", description="Erinnerung erstellen")
  184. @pattern("(erinnere|erinner) [mich] [an] {content}")
  185. @pattern("erinnerung {content}")
  186. @pattern("(richte|erstelle|erstell) [eine] erinnerung [ein|fuer] {content}")
  187. @example("Erinnere mich an Zahnarzt", "Erinnerung Einkaufen")
  188. @example("Richte eine Erinnerung ein", "Erstelle Erinnerung Muell rausbringen")
  189. async def handle_create_reminder(self, data: IntentReceivedData) -> IntentResult:
  190. """Erstellt eine Erinnerung — ggf. mit Rueckfragen."""
  191. session_id = data.session_id or "unknown"
  192. step = self._get_dialog_step(session_id)
  193. # Multi-Turn: Antwort auf "Wofuer?"
  194. if step == "ask_what":
  195. title = data.original_text.strip()
  196. if not title:
  197. return IntentResult.failure(
  198. "Kein Inhalt",
  199. response_text="Ich habe nichts verstanden. Wofuer ist die Erinnerung?",
  200. )
  201. self._set_dialog_data(session_id, "title", title)
  202. self._set_dialog_step(session_id, "ask_when")
  203. return IntentResult(
  204. success=True,
  205. response_text="Wann soll ich dich erinnern?",
  206. follow_up_intent="create_reminder",
  207. )
  208. # Multi-Turn: Antwort auf "Wann?"
  209. if step == "ask_when":
  210. time_text = data.original_text.strip()
  211. return self._handle_when_response(session_id, time_text, data)
  212. # Multi-Turn: VM/NM Klaerung
  213. if step == "clarify_ampm":
  214. return self._handle_ampm_response(session_id, data)
  215. # Erster Aufruf: Pruefe ob Slots gefuellt
  216. title = data.get_slot("content") or data.get_slot("title") or ""
  217. time_slot = data.get_slot("time") or data.get_slot("when") or ""
  218. if not title:
  219. # Kein Titel → Rueckfrage
  220. self._set_dialog_step(session_id, "ask_what")
  221. self._set_dialog_data(session_id, "satellite_id", data.satellite_id)
  222. return IntentResult(
  223. success=True,
  224. response_text="Wofuer ist die Erinnerung?",
  225. follow_up_intent="create_reminder",
  226. )
  227. if not time_slot:
  228. # Titel da, aber keine Zeit → Rueckfrage
  229. self._set_dialog_data(session_id, "title", title)
  230. self._set_dialog_data(session_id, "satellite_id", data.satellite_id)
  231. self._set_dialog_step(session_id, "ask_when")
  232. return IntentResult(
  233. success=True,
  234. response_text="Wann soll ich dich erinnern?",
  235. follow_up_intent="create_reminder",
  236. )
  237. # Alles da → direkt erstellen
  238. return self._try_create_with_time(session_id, title, time_slot, data)
  239. @intent("create_reminder_full", description="Erinnerung mit vollstaendigen Details")
  240. @pattern("(erinnere|erinner) [mich] [morgen|uebermorgen|heute] [um] {time} [uhr] [an] {content}")
  241. @pattern("(erinnere|erinner) [mich] in {time} [an] {content}")
  242. @example("Erinnere mich morgen um 14 Uhr an Zahnarzt")
  243. @example("Erinnere mich in 2 Stunden an Kuchen aus dem Ofen")
  244. async def handle_create_reminder_full(self, data: IntentReceivedData) -> IntentResult:
  245. """Erstellt eine Erinnerung mit allen Details in einem Satz."""
  246. title = data.get_slot("content") or data.get_slot("title") or ""
  247. time_text = data.get_slot("time") or data.get_slot("when") or ""
  248. if not title or not time_text:
  249. # Fallback: Gesamttext parsen
  250. return await self.handle_create_reminder(data)
  251. session_id = data.session_id or "unknown"
  252. return self._try_create_with_time(session_id, title, time_text, data)
  253. def _handle_when_response(self, session_id: str, time_text: str, data: IntentReceivedData) -> IntentResult:
  254. """Verarbeitet die Antwort auf 'Wann?'."""
  255. if not time_text:
  256. return IntentResult.failure(
  257. "Keine Zeitangabe",
  258. response_text="Ich habe die Zeit nicht verstanden. Wann soll ich dich erinnern?",
  259. )
  260. title = self._get_dialog_data(session_id, "title", "Erinnerung")
  261. return self._try_create_with_time(session_id, title, time_text, data)
  262. def _try_create_with_time(self, session_id: str, title: str, time_text: str,
  263. data: IntentReceivedData) -> IntentResult:
  264. """Versucht eine Erinnerung mit geparster Zeit zu erstellen."""
  265. default_time = self.get_config_value("default_time", "09:00")
  266. parsed = parse_german_time(time_text, default_time=default_time)
  267. if parsed.error:
  268. # Zeit nicht erkannt → nochmal fragen
  269. self._set_dialog_data(session_id, "title", title)
  270. self._set_dialog_step(session_id, "ask_when")
  271. return IntentResult(
  272. success=True,
  273. response_text=f"Ich habe die Zeitangabe nicht verstanden. Wann genau?",
  274. follow_up_intent="create_reminder",
  275. )
  276. if parsed.ambiguous:
  277. # VM/NM Rueckfrage
  278. self._set_dialog_data(session_id, "title", title)
  279. self._set_dialog_data(session_id, "time_text", time_text)
  280. self._set_dialog_data(session_id, "option_a", parsed.option_a)
  281. self._set_dialog_data(session_id, "option_b", parsed.option_b)
  282. self._set_dialog_step(session_id, "clarify_ampm")
  283. return IntentResult(
  284. success=True,
  285. response_text=f"Meinst du {parsed.option_a} oder {parsed.option_b}?",
  286. follow_up_intent="create_reminder",
  287. )
  288. # Eindeutig → Erinnerung erstellen
  289. return self._finalize_reminder(session_id, title, parsed.dt, data)
  290. def _handle_ampm_response(self, session_id: str, data: IntentReceivedData) -> IntentResult:
  291. """Verarbeitet die VM/NM Antwort."""
  292. text = data.original_text.lower().strip()
  293. title = self._get_dialog_data(session_id, "title", "Erinnerung")
  294. time_text = self._get_dialog_data(session_id, "time_text", "")
  295. option_a = self._get_dialog_data(session_id, "option_a", "")
  296. option_b = self._get_dialog_data(session_id, "option_b", "")
  297. # Versuche aus der Antwort die Praeferenz zu erkennen
  298. use_pm = False
  299. if any(w in text for w in ("nachmittag", "abend", "pm", "nachm", option_b.lower() if option_b else "")):
  300. use_pm = True
  301. elif any(w in text for w in ("morgen", "vormittag", "frueh", "früh", "am", "vorm", option_a.lower() if option_a else "")):
  302. use_pm = False
  303. else:
  304. # Versuche Zahl zu finden (z.B. "14 Uhr")
  305. import re
  306. match = re.search(r"(\d{1,2})", text)
  307. if match:
  308. hour = int(match.group(1))
  309. if hour >= 13:
  310. use_pm = True
  311. else:
  312. # Unklar → nochmal fragen
  313. return IntentResult(
  314. success=True,
  315. response_text=f"Bitte sage {option_a} oder {option_b}.",
  316. follow_up_intent="create_reminder",
  317. )
  318. # Nochmal parsen mit Kontext
  319. if use_pm:
  320. adjusted_text = time_text + " nachmittags"
  321. else:
  322. adjusted_text = time_text + " morgens"
  323. default_time = self.get_config_value("default_time", "09:00")
  324. parsed = parse_german_time(adjusted_text, default_time=default_time)
  325. if parsed.dt:
  326. return self._finalize_reminder(session_id, title, parsed.dt, data)
  327. # Fallback: Aus den Optionen direkt die Stunde ableiten
  328. import re
  329. match = re.search(r"(\d{1,2})", option_b if use_pm else option_a)
  330. if match:
  331. hour = int(match.group(1))
  332. now = datetime.now()
  333. dt = now.replace(hour=hour, minute=0, second=0, microsecond=0)
  334. if dt <= now:
  335. dt += timedelta(days=1)
  336. return self._finalize_reminder(session_id, title, dt, data)
  337. self._clear_dialog(session_id)
  338. return IntentResult.failure(
  339. "Zeitangabe nicht erkannt",
  340. response_text="Entschuldigung, ich konnte die Zeit nicht bestimmen.",
  341. )
  342. def _finalize_reminder(self, session_id: str, title: str, remind_at: datetime,
  343. data: IntentReceivedData) -> IntentResult:
  344. """Erstellt die Erinnerung und speichert sie."""
  345. self._clear_dialog(session_id)
  346. now = _now_iso()
  347. remind_date = remind_at.strftime("%Y-%m-%d")
  348. item = SyncItem(
  349. data_type=DATA_TYPE,
  350. data={
  351. "title": title,
  352. "remind_at": remind_at.isoformat(),
  353. "remind_date": remind_date,
  354. "recurring": False,
  355. "completed": False,
  356. "notified": False,
  357. },
  358. created_at=now,
  359. updated_at=now,
  360. source_id=data.satellite_id or "standalone",
  361. )
  362. self._store.save(item)
  363. # Benutzerfreundliche Zeitangabe
  364. time_str = self._format_remind_time(remind_at)
  365. pinfo(f"RemindersPlugin: Erinnerung erstellt: {title} um {time_str}")
  366. return IntentResult.success_with_response(
  367. f"Erinnerung fuer {title} {time_str} gespeichert."
  368. )
  369. def _format_remind_time(self, dt: datetime) -> str:
  370. """Formatiert ein datetime benutzerfreundlich."""
  371. now = datetime.now()
  372. today = now.date()
  373. target_date = dt.date()
  374. time_str = dt.strftime("%H:%M Uhr")
  375. if target_date == today:
  376. return f"heute um {time_str}"
  377. if target_date == today + timedelta(days=1):
  378. return f"morgen um {time_str}"
  379. if target_date == today + timedelta(days=2):
  380. return f"uebermorgen um {time_str}"
  381. wochentage = ["Montag", "Dienstag", "Mittwoch", "Donnerstag",
  382. "Freitag", "Samstag", "Sonntag"]
  383. diff = (target_date - today).days
  384. if 0 < diff <= 7:
  385. tag = wochentage[target_date.weekday()]
  386. return f"am {tag} um {time_str}"
  387. return f"am {dt.strftime('%d.%m.%Y')} um {time_str}"
  388. # =========================================================================
  389. # Intent-Handler: Erinnerungen auflisten
  390. # =========================================================================
  391. @intent("list_reminders", description="Alle Erinnerungen auflisten")
  392. @pattern("zeig [mir] [meine|alle] erinnerungen")
  393. @pattern("welche erinnerungen [habe ich]")
  394. @pattern("liste [meine|alle] erinnerungen [auf]")
  395. @pattern("meine erinnerungen")
  396. @example("Zeige meine Erinnerungen", "Welche Erinnerungen habe ich?")
  397. async def handle_list_reminders(self, data: IntentReceivedData) -> IntentResult:
  398. """Listet alle aktiven Erinnerungen auf."""
  399. reminders = self._get_active_reminders()
  400. if not reminders:
  401. return IntentResult.success_with_response(
  402. "Du hast keine Erinnerungen."
  403. )
  404. if len(reminders) == 1:
  405. r = reminders[0]
  406. title = r.data.get("title", "Unbenannt")
  407. remind_at = r.data.get("remind_at", "")
  408. time_str = self._format_remind_at(remind_at)
  409. return IntentResult.success_with_response(
  410. f"Du hast eine Erinnerung: {title} {time_str}."
  411. )
  412. lines = []
  413. for r in reminders[:5]:
  414. title = r.data.get("title", "Unbenannt")
  415. remind_at = r.data.get("remind_at", "")
  416. time_str = self._format_remind_at(remind_at)
  417. lines.append(f"{title} {time_str}")
  418. text = ", ".join(lines)
  419. total = len(reminders)
  420. if total > 5:
  421. return IntentResult.success_with_response(
  422. f"Du hast {total} Erinnerungen. Die naechsten fuenf: {text}."
  423. )
  424. return IntentResult.success_with_response(
  425. f"Du hast {total} Erinnerungen: {text}."
  426. )
  427. @intent("list_reminders_today", description="Heutige Erinnerungen anzeigen")
  428. @pattern("was steht heute an")
  429. @pattern("erinnerungen [fuer|von] heute")
  430. @pattern("heutige erinnerungen")
  431. @example("Was steht heute an?", "Erinnerungen fuer heute")
  432. async def handle_list_reminders_today(self, data: IntentReceivedData) -> IntentResult:
  433. """Zeigt Erinnerungen fuer heute."""
  434. today = datetime.now().strftime("%Y-%m-%d")
  435. reminders = self._get_active_reminders()
  436. today_reminders = [r for r in reminders if r.data.get("remind_date") == today]
  437. if not today_reminders:
  438. return IntentResult.success_with_response(
  439. "Fuer heute stehen keine Erinnerungen an."
  440. )
  441. if len(today_reminders) == 1:
  442. r = today_reminders[0]
  443. title = r.data.get("title", "Unbenannt")
  444. remind_at = r.data.get("remind_at", "")
  445. time_str = self._format_remind_at(remind_at)
  446. return IntentResult.success_with_response(
  447. f"Heute steht an: {title} {time_str}."
  448. )
  449. lines = []
  450. for r in today_reminders:
  451. title = r.data.get("title", "Unbenannt")
  452. remind_at = r.data.get("remind_at", "")
  453. time_str = self._format_remind_at(remind_at)
  454. lines.append(f"{title} {time_str}")
  455. return IntentResult.success_with_response(
  456. f"Heute stehen {len(today_reminders)} Erinnerungen an: {', '.join(lines)}."
  457. )
  458. # =========================================================================
  459. # Intent-Handler: Erinnerung loeschen
  460. # =========================================================================
  461. @intent("delete_reminder", description="Erinnerung loeschen")
  462. @pattern("(loesche|entferne|loesch|entfern) [die] erinnerung {query}")
  463. @pattern("erinnerung {query} (loeschen|entfernen)")
  464. @example("Loesche Erinnerung Zahnarzt", "Entferne die Erinnerung Einkaufen")
  465. async def handle_delete_reminder(self, data: IntentReceivedData) -> IntentResult:
  466. """Loescht eine Erinnerung (Soft-Delete)."""
  467. query = data.get_slot("query", "")
  468. if not query:
  469. return IntentResult.failure(
  470. "Kein Suchbegriff",
  471. response_text="Welche Erinnerung soll ich loeschen?",
  472. )
  473. query_lower = query.lower()
  474. reminders = self._get_active_reminders()
  475. matches = [
  476. r for r in reminders
  477. if query_lower in r.data.get("title", "").lower()
  478. ]
  479. if not matches:
  480. return IntentResult.failure(
  481. f"Erinnerung '{query}' nicht gefunden",
  482. response_text=f"Ich habe keine Erinnerung mit '{query}' gefunden.",
  483. )
  484. if len(matches) > 1:
  485. titles = [m.data.get("title", "Unbenannt") for m in matches]
  486. return IntentResult.failure(
  487. "Mehrere Treffer",
  488. response_text=f"Mehrere Erinnerungen gefunden: {', '.join(titles[:3])}. Welche soll ich loeschen?",
  489. )
  490. reminder = matches[0]
  491. self._store.delete(DATA_TYPE, reminder.item_id)
  492. title = reminder.data.get("title", "Unbenannt")
  493. pinfo(f"RemindersPlugin: Erinnerung geloescht: {title}")
  494. return IntentResult.success_with_response(f"Erinnerung '{title}' geloescht.")
  495. @intent("delete_all_reminders", description="Alle Erinnerungen loeschen")
  496. @pattern("(loesche|entferne|loesch|entfern) alle erinnerungen")
  497. @example("Loesche alle Erinnerungen")
  498. async def handle_delete_all_reminders(self, data: IntentReceivedData) -> IntentResult:
  499. """Startet Bulk-Delete mit Rueckfrage."""
  500. reminders = self._get_active_reminders()
  501. if not reminders:
  502. return IntentResult.success_with_response("Du hast keine Erinnerungen.")
  503. session_id = data.session_id or "unknown"
  504. self._pending_delete_all[session_id] = True
  505. return IntentResult(
  506. success=True,
  507. response_text=f"Du hast {len(reminders)} Erinnerungen. Bist du sicher? Sage Ja oder Nein.",
  508. follow_up_intent="confirm_delete_all_reminders",
  509. )
  510. @intent("confirm_delete_all_reminders", description="Bestaetigung Alle-Loeschen")
  511. async def handle_confirm_delete_all(self, data: IntentReceivedData) -> IntentResult:
  512. """Verarbeitet die Bestaetigung fuer Bulk-Delete."""
  513. session_id = data.session_id or "unknown"
  514. if session_id not in self._pending_delete_all:
  515. return IntentResult.failure(
  516. "Keine ausstehende Loeschung",
  517. response_text="Es gibt keine ausstehende Loeschanfrage.",
  518. )
  519. text = data.original_text.lower().strip()
  520. self._pending_delete_all.pop(session_id, None)
  521. if text in ("ja", "yes", "klar", "sicher", "mach", "ok", "genau"):
  522. reminders = self._get_active_reminders()
  523. count = 0
  524. for r in reminders:
  525. self._store.delete(DATA_TYPE, r.item_id)
  526. count += 1
  527. pinfo(f"RemindersPlugin: {count} Erinnerungen geloescht (Bulk)")
  528. return IntentResult.success_with_response(
  529. f"Alle {count} Erinnerungen wurden geloescht."
  530. )
  531. return IntentResult.success_with_response("Abgebrochen. Erinnerungen bleiben erhalten.")
  532. # =========================================================================
  533. # Hilfsmethoden
  534. # =========================================================================
  535. def _get_active_reminders(self) -> list[SyncItem]:
  536. """Gibt alle aktiven (nicht abgeschlossenen) Erinnerungen zurueck."""
  537. all_reminders = self._store.get_all(DATA_TYPE)
  538. return [r for r in all_reminders if not r.data.get("completed") and not r.data.get("notified")]
  539. def _format_remind_at(self, remind_at_str: str) -> str:
  540. """Formatiert einen ISO-Zeitstring benutzerfreundlich."""
  541. if not remind_at_str:
  542. return ""
  543. try:
  544. dt = datetime.fromisoformat(remind_at_str)
  545. if dt.tzinfo:
  546. dt = dt.replace(tzinfo=None)
  547. return self._format_remind_time(dt)
  548. except (ValueError, TypeError):
  549. return ""