# -*- coding: utf-8 -*- """ Reminders-Plugin fuer Trixy. Ermoeglicht das Erstellen, Auflisten und Loeschen von Erinnerungen per Sprache. Unterstuetzt Multi-Turn-Dialoge mit Rueckfragen (Was? → Wann? → VM/NM?). Bei Faelligkeit wird eine TTS-Meldung abgespielt. Nutzt den SyncStore fuer persistente Speicherung und automatische Synchronisation mit dem Server. """ import asyncio from datetime import datetime, timedelta from pathlib import Path from typing import Any from trixy_core.plugins import TrixyPlugin from trixy_core.sync.models import SyncItem, _now_iso from trixy_core.sync.store import SyncStore from trixy_core.nlp.intent_registry import IntentRegistry from trixy_core.nlp.decorators import intent, pattern, example from trixy_core.nlp.handler import IntentReceivedData, IntentResult from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn from plugins.reminders.time_parser import parse_german_time, ParsedTime DATA_TYPE = "reminders.reminder" class RemindersPlugin(TrixyPlugin): """ Erinnerungen-Plugin mit Multi-Turn-Dialog. Bietet: - Erinnerungen erstellen (mit Rueckfragen: Was? Wann? VM/NM?) - Erinnerungen mit allen Details in einem Satz - Alle Erinnerungen auflisten - Heutige Erinnerungen anzeigen - Erinnerungen loeschen (einzeln und alle) - Timer-Loop prueft alle 60s auf faellige Erinnerungen Nutzt SyncStore fuer Persistenz und Sync. """ NAME = "reminders" VERSION = "1.0.0" DESCRIPTION = "Erinnerungen per Sprachbefehl erstellen und verwalten" AUTHOR = "Trixy" def __init__(self, application: Any, plugin_path: Any, config: dict[str, Any] | None = None) -> None: super().__init__(application, plugin_path, config) self._registry = IntentRegistry.get_instance() self._store: SyncStore | None = None # Dialog-State fuer Multi-Turn (session_id → state) self._dialog_state: dict[str, dict[str, Any]] = {} # Pending delete-all Rueckfragen self._pending_delete_all: dict[str, bool] = {} # Hintergrund-Task fuer Erinnerungs-Pruefung self._check_task: asyncio.Task | None = None # Notification-Sound (PCM) self._notification_pcm: bytes | None = None # ========================================================================= # Lifecycle # ========================================================================= async def on_load(self) -> None: """Initialisiert das Plugin.""" pinfo(f"RemindersPlugin: Lade Plugin v{self.VERSION}") # SyncStore holen self._store = self.application.sync_store if self._store is None: self._store = SyncStore(base_directory="./sync_data") pdebug("RemindersPlugin: Eigenen SyncStore erstellt (kein globaler verfuegbar)") # Notification-Sound vorladen self._load_notification_sound() # Hintergrund-Task starten fuer faellige Erinnerungen self._check_task = asyncio.create_task(self._check_reminders_loop()) pinfo("RemindersPlugin: Geladen") async def on_unload(self) -> None: """Entlaedt das Plugin.""" # Hintergrund-Task stoppen if self._check_task and not self._check_task.done(): self._check_task.cancel() try: await self._check_task except asyncio.CancelledError: pass self._registry.unregister_plugin(self.NAME) self._dialog_state.clear() self._pending_delete_all.clear() self._notification_pcm = None pdebug("RemindersPlugin: Entladen") # ========================================================================= # Notification-Sound # ========================================================================= def _load_notification_sound(self) -> None: """Laedt den Notification-Sound und entfernt den WAV-Header.""" sound_name = self.get_config_value("notification_sound", "notification.wav") asset_service = self.application.services.get_service("AssetService") if not asset_service: return audio_path: Path | None = asset_service.get_audio(sound_name) if not audio_path or not audio_path.exists(): return try: raw = audio_path.read_bytes() self._notification_pcm = raw[44:] pdebug(f"RemindersPlugin: Notification-Sound geladen ({len(self._notification_pcm)} bytes)") except Exception as e: perror(f"RemindersPlugin: Notification-Sound Fehler: {e}") # ========================================================================= # Hintergrund-Pruefung faelliger Erinnerungen # ========================================================================= async def _check_reminders_loop(self) -> None: """Prueft alle 60 Sekunden ob eine Erinnerung faellig ist.""" interval = self.get_config_value("check_interval_seconds", 60) while True: try: await asyncio.sleep(interval) await self._check_due_reminders() except asyncio.CancelledError: break except Exception as e: perror(f"RemindersPlugin: Fehler in Check-Loop: {e}") async def _check_due_reminders(self) -> None: """Prueft und benachrichtigt faellige Erinnerungen.""" if not self._store: return now = datetime.now() reminders = self._store.get_all(DATA_TYPE) for reminder in reminders: if reminder.data.get("notified") or reminder.data.get("completed"): continue remind_at_str = reminder.data.get("remind_at", "") if not remind_at_str: continue try: remind_at = datetime.fromisoformat(remind_at_str) # Timezone-naive Vergleich if remind_at.tzinfo: remind_at = remind_at.replace(tzinfo=None) except (ValueError, TypeError): continue if remind_at <= now: await self._trigger_reminder(reminder) async def _trigger_reminder(self, reminder: SyncItem) -> None: """Benachrichtigt ueber eine faellige Erinnerung.""" title = reminder.data.get("title", "Erinnerung") pinfo(f"RemindersPlugin: Erinnerung faellig: {title}") # Als benachrichtigt markieren reminder.data["notified"] = True reminder.data["completed"] = True reminder.updated_at = _now_iso() reminder.version += 1 self._store.save(reminder) # TTS-Meldung via Event (funktioniert im Server- und Standalone-Modus) text = f"Erinnerung: {title}." await self.application.events.emit("tts_request", { "request_id": f"reminder-{reminder.item_id}", "satellite_id": reminder.source_id or "", "text": text, }) # ========================================================================= # Dialog-State Verwaltung # ========================================================================= def _get_dialog_step(self, session_id: str) -> str: """Gibt den aktuellen Dialog-Schritt zurueck.""" state = self._dialog_state.get(session_id) if state: return state.get("step", "") return "" def _set_dialog_step(self, session_id: str, step: str) -> None: """Setzt den Dialog-Schritt.""" if session_id not in self._dialog_state: self._dialog_state[session_id] = {} self._dialog_state[session_id]["step"] = step def _set_dialog_data(self, session_id: str, key: str, value: Any) -> None: """Speichert einen Wert im Dialog-State.""" if session_id not in self._dialog_state: self._dialog_state[session_id] = {} self._dialog_state[session_id][key] = value def _get_dialog_data(self, session_id: str, key: str, default: Any = None) -> Any: """Liest einen Wert aus dem Dialog-State.""" state = self._dialog_state.get(session_id) if state: return state.get(key, default) return default def _clear_dialog(self, session_id: str) -> None: """Loescht den Dialog-State fuer eine Session.""" self._dialog_state.pop(session_id, None) # ========================================================================= # Intent-Handler: Erinnerung erstellen (Multi-Turn) # ========================================================================= @intent("create_reminder", description="Erinnerung erstellen") @pattern("(erinnere|erinner) [mich] [an] {content}") @pattern("erinnerung {content}") @pattern("(richte|erstelle|erstell) [eine] erinnerung [ein|fuer] {content}") @example("Erinnere mich an Zahnarzt", "Erinnerung Einkaufen") @example("Richte eine Erinnerung ein", "Erstelle Erinnerung Muell rausbringen") async def handle_create_reminder(self, data: IntentReceivedData) -> IntentResult: """Erstellt eine Erinnerung — ggf. mit Rueckfragen.""" session_id = data.session_id or "unknown" step = self._get_dialog_step(session_id) # Multi-Turn: Antwort auf "Wofuer?" if step == "ask_what": title = data.original_text.strip() if not title: return IntentResult.failure( "Kein Inhalt", response_text="Ich habe nichts verstanden. Wofuer ist die Erinnerung?", ) self._set_dialog_data(session_id, "title", title) self._set_dialog_step(session_id, "ask_when") return IntentResult( success=True, response_text="Wann soll ich dich erinnern?", follow_up_intent="create_reminder", ) # Multi-Turn: Antwort auf "Wann?" if step == "ask_when": time_text = data.original_text.strip() return self._handle_when_response(session_id, time_text, data) # Multi-Turn: VM/NM Klaerung if step == "clarify_ampm": return self._handle_ampm_response(session_id, data) # Erster Aufruf: Pruefe ob Slots gefuellt title = data.get_slot("content") or data.get_slot("title") or "" time_slot = data.get_slot("time") or data.get_slot("when") or "" if not title: # Kein Titel → Rueckfrage self._set_dialog_step(session_id, "ask_what") self._set_dialog_data(session_id, "satellite_id", data.satellite_id) return IntentResult( success=True, response_text="Wofuer ist die Erinnerung?", follow_up_intent="create_reminder", ) if not time_slot: # Titel da, aber keine Zeit → Rueckfrage self._set_dialog_data(session_id, "title", title) self._set_dialog_data(session_id, "satellite_id", data.satellite_id) self._set_dialog_step(session_id, "ask_when") return IntentResult( success=True, response_text="Wann soll ich dich erinnern?", follow_up_intent="create_reminder", ) # Alles da → direkt erstellen return self._try_create_with_time(session_id, title, time_slot, data) @intent("create_reminder_full", description="Erinnerung mit vollstaendigen Details") @pattern("(erinnere|erinner) [mich] [morgen|uebermorgen|heute] [um] {time} [uhr] [an] {content}") @pattern("(erinnere|erinner) [mich] in {time} [an] {content}") @example("Erinnere mich morgen um 14 Uhr an Zahnarzt") @example("Erinnere mich in 2 Stunden an Kuchen aus dem Ofen") async def handle_create_reminder_full(self, data: IntentReceivedData) -> IntentResult: """Erstellt eine Erinnerung mit allen Details in einem Satz.""" title = data.get_slot("content") or data.get_slot("title") or "" time_text = data.get_slot("time") or data.get_slot("when") or "" if not title or not time_text: # Fallback: Gesamttext parsen return await self.handle_create_reminder(data) session_id = data.session_id or "unknown" return self._try_create_with_time(session_id, title, time_text, data) def _handle_when_response(self, session_id: str, time_text: str, data: IntentReceivedData) -> IntentResult: """Verarbeitet die Antwort auf 'Wann?'.""" if not time_text: return IntentResult.failure( "Keine Zeitangabe", response_text="Ich habe die Zeit nicht verstanden. Wann soll ich dich erinnern?", ) title = self._get_dialog_data(session_id, "title", "Erinnerung") return self._try_create_with_time(session_id, title, time_text, data) def _try_create_with_time(self, session_id: str, title: str, time_text: str, data: IntentReceivedData) -> IntentResult: """Versucht eine Erinnerung mit geparster Zeit zu erstellen.""" default_time = self.get_config_value("default_time", "09:00") parsed = parse_german_time(time_text, default_time=default_time) if parsed.error: # Zeit nicht erkannt → nochmal fragen self._set_dialog_data(session_id, "title", title) self._set_dialog_step(session_id, "ask_when") return IntentResult( success=True, response_text=f"Ich habe die Zeitangabe nicht verstanden. Wann genau?", follow_up_intent="create_reminder", ) if parsed.ambiguous: # VM/NM Rueckfrage self._set_dialog_data(session_id, "title", title) self._set_dialog_data(session_id, "time_text", time_text) self._set_dialog_data(session_id, "option_a", parsed.option_a) self._set_dialog_data(session_id, "option_b", parsed.option_b) self._set_dialog_step(session_id, "clarify_ampm") return IntentResult( success=True, response_text=f"Meinst du {parsed.option_a} oder {parsed.option_b}?", follow_up_intent="create_reminder", ) # Eindeutig → Erinnerung erstellen return self._finalize_reminder(session_id, title, parsed.dt, data) def _handle_ampm_response(self, session_id: str, data: IntentReceivedData) -> IntentResult: """Verarbeitet die VM/NM Antwort.""" text = data.original_text.lower().strip() title = self._get_dialog_data(session_id, "title", "Erinnerung") time_text = self._get_dialog_data(session_id, "time_text", "") option_a = self._get_dialog_data(session_id, "option_a", "") option_b = self._get_dialog_data(session_id, "option_b", "") # Versuche aus der Antwort die Praeferenz zu erkennen use_pm = False if any(w in text for w in ("nachmittag", "abend", "pm", "nachm", option_b.lower() if option_b else "")): use_pm = True elif any(w in text for w in ("morgen", "vormittag", "frueh", "früh", "am", "vorm", option_a.lower() if option_a else "")): use_pm = False else: # Versuche Zahl zu finden (z.B. "14 Uhr") import re match = re.search(r"(\d{1,2})", text) if match: hour = int(match.group(1)) if hour >= 13: use_pm = True else: # Unklar → nochmal fragen return IntentResult( success=True, response_text=f"Bitte sage {option_a} oder {option_b}.", follow_up_intent="create_reminder", ) # Nochmal parsen mit Kontext if use_pm: adjusted_text = time_text + " nachmittags" else: adjusted_text = time_text + " morgens" default_time = self.get_config_value("default_time", "09:00") parsed = parse_german_time(adjusted_text, default_time=default_time) if parsed.dt: return self._finalize_reminder(session_id, title, parsed.dt, data) # Fallback: Aus den Optionen direkt die Stunde ableiten import re match = re.search(r"(\d{1,2})", option_b if use_pm else option_a) if match: hour = int(match.group(1)) now = datetime.now() dt = now.replace(hour=hour, minute=0, second=0, microsecond=0) if dt <= now: dt += timedelta(days=1) return self._finalize_reminder(session_id, title, dt, data) self._clear_dialog(session_id) return IntentResult.failure( "Zeitangabe nicht erkannt", response_text="Entschuldigung, ich konnte die Zeit nicht bestimmen.", ) def _finalize_reminder(self, session_id: str, title: str, remind_at: datetime, data: IntentReceivedData) -> IntentResult: """Erstellt die Erinnerung und speichert sie.""" self._clear_dialog(session_id) now = _now_iso() remind_date = remind_at.strftime("%Y-%m-%d") item = SyncItem( data_type=DATA_TYPE, data={ "title": title, "remind_at": remind_at.isoformat(), "remind_date": remind_date, "recurring": False, "completed": False, "notified": False, }, created_at=now, updated_at=now, source_id=data.satellite_id or "standalone", ) self._store.save(item) # Benutzerfreundliche Zeitangabe time_str = self._format_remind_time(remind_at) pinfo(f"RemindersPlugin: Erinnerung erstellt: {title} um {time_str}") return IntentResult.success_with_response( f"Erinnerung fuer {title} {time_str} gespeichert." ) def _format_remind_time(self, dt: datetime) -> str: """Formatiert ein datetime benutzerfreundlich.""" now = datetime.now() today = now.date() target_date = dt.date() time_str = dt.strftime("%H:%M Uhr") if target_date == today: return f"heute um {time_str}" if target_date == today + timedelta(days=1): return f"morgen um {time_str}" if target_date == today + timedelta(days=2): return f"uebermorgen um {time_str}" wochentage = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] diff = (target_date - today).days if 0 < diff <= 7: tag = wochentage[target_date.weekday()] return f"am {tag} um {time_str}" return f"am {dt.strftime('%d.%m.%Y')} um {time_str}" # ========================================================================= # Intent-Handler: Erinnerungen auflisten # ========================================================================= @intent("list_reminders", description="Alle Erinnerungen auflisten") @pattern("zeig [mir] [meine|alle] erinnerungen") @pattern("welche erinnerungen [habe ich]") @pattern("liste [meine|alle] erinnerungen [auf]") @pattern("meine erinnerungen") @example("Zeige meine Erinnerungen", "Welche Erinnerungen habe ich?") async def handle_list_reminders(self, data: IntentReceivedData) -> IntentResult: """Listet alle aktiven Erinnerungen auf.""" reminders = self._get_active_reminders() if not reminders: return IntentResult.success_with_response( "Du hast keine Erinnerungen." ) if len(reminders) == 1: r = reminders[0] title = r.data.get("title", "Unbenannt") remind_at = r.data.get("remind_at", "") time_str = self._format_remind_at(remind_at) return IntentResult.success_with_response( f"Du hast eine Erinnerung: {title} {time_str}." ) lines = [] for r in reminders[:5]: title = r.data.get("title", "Unbenannt") remind_at = r.data.get("remind_at", "") time_str = self._format_remind_at(remind_at) lines.append(f"{title} {time_str}") text = ", ".join(lines) total = len(reminders) if total > 5: return IntentResult.success_with_response( f"Du hast {total} Erinnerungen. Die naechsten fuenf: {text}." ) return IntentResult.success_with_response( f"Du hast {total} Erinnerungen: {text}." ) @intent("list_reminders_today", description="Heutige Erinnerungen anzeigen") @pattern("was steht heute an") @pattern("erinnerungen [fuer|von] heute") @pattern("heutige erinnerungen") @example("Was steht heute an?", "Erinnerungen fuer heute") async def handle_list_reminders_today(self, data: IntentReceivedData) -> IntentResult: """Zeigt Erinnerungen fuer heute.""" today = datetime.now().strftime("%Y-%m-%d") reminders = self._get_active_reminders() today_reminders = [r for r in reminders if r.data.get("remind_date") == today] if not today_reminders: return IntentResult.success_with_response( "Fuer heute stehen keine Erinnerungen an." ) if len(today_reminders) == 1: r = today_reminders[0] title = r.data.get("title", "Unbenannt") remind_at = r.data.get("remind_at", "") time_str = self._format_remind_at(remind_at) return IntentResult.success_with_response( f"Heute steht an: {title} {time_str}." ) lines = [] for r in today_reminders: title = r.data.get("title", "Unbenannt") remind_at = r.data.get("remind_at", "") time_str = self._format_remind_at(remind_at) lines.append(f"{title} {time_str}") return IntentResult.success_with_response( f"Heute stehen {len(today_reminders)} Erinnerungen an: {', '.join(lines)}." ) # ========================================================================= # Intent-Handler: Erinnerung loeschen # ========================================================================= @intent("delete_reminder", description="Erinnerung loeschen") @pattern("(loesche|entferne|loesch|entfern) [die] erinnerung {query}") @pattern("erinnerung {query} (loeschen|entfernen)") @example("Loesche Erinnerung Zahnarzt", "Entferne die Erinnerung Einkaufen") async def handle_delete_reminder(self, data: IntentReceivedData) -> IntentResult: """Loescht eine Erinnerung (Soft-Delete).""" query = data.get_slot("query", "") if not query: return IntentResult.failure( "Kein Suchbegriff", response_text="Welche Erinnerung soll ich loeschen?", ) query_lower = query.lower() reminders = self._get_active_reminders() matches = [ r for r in reminders if query_lower in r.data.get("title", "").lower() ] if not matches: return IntentResult.failure( f"Erinnerung '{query}' nicht gefunden", response_text=f"Ich habe keine Erinnerung mit '{query}' gefunden.", ) if len(matches) > 1: titles = [m.data.get("title", "Unbenannt") for m in matches] return IntentResult.failure( "Mehrere Treffer", response_text=f"Mehrere Erinnerungen gefunden: {', '.join(titles[:3])}. Welche soll ich loeschen?", ) reminder = matches[0] self._store.delete(DATA_TYPE, reminder.item_id) title = reminder.data.get("title", "Unbenannt") pinfo(f"RemindersPlugin: Erinnerung geloescht: {title}") return IntentResult.success_with_response(f"Erinnerung '{title}' geloescht.") @intent("delete_all_reminders", description="Alle Erinnerungen loeschen") @pattern("(loesche|entferne|loesch|entfern) alle erinnerungen") @example("Loesche alle Erinnerungen") async def handle_delete_all_reminders(self, data: IntentReceivedData) -> IntentResult: """Startet Bulk-Delete mit Rueckfrage.""" reminders = self._get_active_reminders() if not reminders: return IntentResult.success_with_response("Du hast keine Erinnerungen.") session_id = data.session_id or "unknown" self._pending_delete_all[session_id] = True return IntentResult( success=True, response_text=f"Du hast {len(reminders)} Erinnerungen. Bist du sicher? Sage Ja oder Nein.", follow_up_intent="confirm_delete_all_reminders", ) @intent("confirm_delete_all_reminders", description="Bestaetigung Alle-Loeschen") async def handle_confirm_delete_all(self, data: IntentReceivedData) -> IntentResult: """Verarbeitet die Bestaetigung fuer Bulk-Delete.""" session_id = data.session_id or "unknown" if session_id not in self._pending_delete_all: return IntentResult.failure( "Keine ausstehende Loeschung", response_text="Es gibt keine ausstehende Loeschanfrage.", ) text = data.original_text.lower().strip() self._pending_delete_all.pop(session_id, None) if text in ("ja", "yes", "klar", "sicher", "mach", "ok", "genau"): reminders = self._get_active_reminders() count = 0 for r in reminders: self._store.delete(DATA_TYPE, r.item_id) count += 1 pinfo(f"RemindersPlugin: {count} Erinnerungen geloescht (Bulk)") return IntentResult.success_with_response( f"Alle {count} Erinnerungen wurden geloescht." ) return IntentResult.success_with_response("Abgebrochen. Erinnerungen bleiben erhalten.") # ========================================================================= # Hilfsmethoden # ========================================================================= def _get_active_reminders(self) -> list[SyncItem]: """Gibt alle aktiven (nicht abgeschlossenen) Erinnerungen zurueck.""" all_reminders = self._store.get_all(DATA_TYPE) return [r for r in all_reminders if not r.data.get("completed") and not r.data.get("notified")] def _format_remind_at(self, remind_at_str: str) -> str: """Formatiert einen ISO-Zeitstring benutzerfreundlich.""" if not remind_at_str: return "" try: dt = datetime.fromisoformat(remind_at_str) if dt.tzinfo: dt = dt.replace(tzinfo=None) return self._format_remind_time(dt) except (ValueError, TypeError): return ""