main.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. # -*- coding: utf-8 -*-
  2. """
  3. Trixy Email-Plugin.
  4. Prueft E-Mail-Konten via IMAP, benachrichtigt bei neuen Mails
  5. und beantwortet Fragen zu E-Mails per Sprachsteuerung.
  6. Konfiguration analog zum Kalender-Plugin:
  7. - Accounts koennen per Satellite oder global zugewiesen werden.
  8. - Periodische Pruefung auf neue E-Mails mit Benachrichtigung.
  9. """
  10. from __future__ import annotations
  11. import asyncio
  12. from datetime import datetime
  13. from trixy_core.plugins.trixy_plugin import TrixyPlugin
  14. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  15. from trixy_core.utils.logging.file_router import log_as
  16. from plugins.email.imap_client import ImapClient, EmailMessage
  17. from plugins.email.mapping import SatelliteEmailMapper
  18. # Intent-Dekoratoren
  19. from trixy_core.nlp.decorators import intent, pattern, example
  20. from trixy_core.nlp.handler import IntentResult, IntentReceivedData
  21. class EmailPlugin(TrixyPlugin):
  22. """E-Mail-Plugin: Zugriff auf IMAP-Konten per Sprache."""
  23. NAME = "email"
  24. VERSION = "1.0.0"
  25. DESCRIPTION = "E-Mail-Benachrichtigungen und Abfragen per Sprache"
  26. AUTHOR = "Trixy"
  27. def __init__(self, **kwargs) -> None:
  28. super().__init__(**kwargs)
  29. self._mapper: SatelliteEmailMapper | None = None
  30. self._clients: dict[str, ImapClient] = {}
  31. self._check_task: asyncio.Task | None = None
  32. self._notification_pcm: bytes = b""
  33. # Ungelesen-Zaehler pro Account (fuer Benachrichtigungen)
  34. self._last_unseen_counts: dict[str, int] = {}
  35. # Letzte abgerufene Mails pro Satellite (fuer "lies vor")
  36. self._last_fetched: dict[str, list[EmailMessage]] = {}
  37. async def on_load(self) -> None:
  38. """Initialisiert Mapper, IMAP-Clients und Benachrichtigungs-Sound."""
  39. self._mapper = SatelliteEmailMapper(self.config, self.application)
  40. # IMAP-Clients erstellen
  41. for key in self._mapper.get_all_account_keys():
  42. account_cfg = self._mapper.get_account_config(key)
  43. if not account_cfg.get("host"):
  44. pwarn(f"Email-Account '{key}' hat keinen Host — uebersprungen")
  45. continue
  46. self._clients[key] = ImapClient(account_cfg)
  47. if not self._clients:
  48. pwarn("Keine gueltigen E-Mail-Accounts konfiguriert")
  49. # Benachrichtigungs-Sound laden
  50. self._load_notification_sound()
  51. # Initiale Zaehler setzen (ohne Benachrichtigung)
  52. await self._init_unseen_counts()
  53. # Periodische Pruefung starten
  54. interval = self.get_config_value("check_interval_minutes", 5) * 60
  55. self._check_task = asyncio.create_task(self._check_loop(interval))
  56. pinfo(f"Email-Plugin geladen: {len(self._clients)} Account(s)")
  57. async def on_unload(self) -> None:
  58. """Stoppt die periodische Pruefung."""
  59. if self._check_task and not self._check_task.done():
  60. self._check_task.cancel()
  61. try:
  62. await self._check_task
  63. except asyncio.CancelledError:
  64. pass
  65. self._check_task = None
  66. self._clients.clear()
  67. pinfo("Email-Plugin entladen")
  68. def _load_notification_sound(self) -> None:
  69. """Laedt den Benachrichtigungs-Sound aus den Assets."""
  70. sound_name = self.get_config_value("notification_sound", "notification.wav")
  71. try:
  72. asset_service = self.application.services.get_service("AssetService")
  73. if not asset_service:
  74. pdebug("AssetService nicht verfuegbar")
  75. return
  76. audio_path = asset_service.get_audio(sound_name)
  77. if audio_path and audio_path.exists():
  78. raw = audio_path.read_bytes()
  79. self._notification_pcm = raw[44:] if len(raw) > 44 else raw
  80. pdebug(f"Notification-Sound geladen: {sound_name}")
  81. else:
  82. pdebug(f"Notification-Sound nicht gefunden: {sound_name}")
  83. except Exception as e:
  84. pdebug(f"Notification-Sound Fehler: {e}")
  85. async def _init_unseen_counts(self) -> None:
  86. """Setzt die initialen Ungelesen-Zaehler (ohne Benachrichtigung)."""
  87. for key, client in self._clients.items():
  88. try:
  89. count = await client.count_unseen()
  90. self._last_unseen_counts[key] = count
  91. pdebug(f"Email '{key}': {count} ungelesen (initial)")
  92. except Exception as e:
  93. perror(f"Email '{key}' Initialpruefung fehlgeschlagen: {e}")
  94. self._last_unseen_counts[key] = 0
  95. # =========================================================================
  96. # Periodische Pruefung
  97. # =========================================================================
  98. async def _check_loop(self, interval: float) -> None:
  99. """Prueft periodisch auf neue E-Mails."""
  100. while True:
  101. try:
  102. await asyncio.sleep(interval)
  103. with log_as("plugin.email"):
  104. await self._check_for_new_emails()
  105. except asyncio.CancelledError:
  106. break
  107. except Exception as e:
  108. perror(f"Email-Pruefung fehlgeschlagen: {e}")
  109. async def _check_for_new_emails(self) -> None:
  110. """Prueft alle Accounts auf neue E-Mails und benachrichtigt."""
  111. for key, client in self._clients.items():
  112. try:
  113. current = await client.count_unseen()
  114. previous = self._last_unseen_counts.get(key, 0)
  115. if current > previous:
  116. new_count = current - previous
  117. pinfo(f"Email '{key}': {new_count} neue E-Mail(s)")
  118. await self._notify_new_emails(key, new_count)
  119. self._last_unseen_counts[key] = current
  120. except Exception as e:
  121. perror(f"Email '{key}' Pruefung fehlgeschlagen: {e}")
  122. async def _notify_new_emails(self, account_key: str, new_count: int) -> None:
  123. """Benachrichtigt zugehoerige Satellites ueber neue E-Mails."""
  124. if not self._mapper:
  125. return
  126. satellite_ids = self._mapper.get_satellites_for_account(account_key)
  127. if not satellite_ids:
  128. # Standalone-Modus: Nur TTS
  129. if new_count == 1:
  130. text = "Du hast eine neue E-Mail."
  131. else:
  132. text = f"Du hast {new_count} neue E-Mails."
  133. await self.application.events.emit("tts_request", {
  134. "request_id": f"email-notify-{account_key}",
  135. "satellite_id": "",
  136. "text": text,
  137. })
  138. return
  139. satellites = getattr(self.application, "satellites", None)
  140. if not satellites:
  141. return
  142. for sat_id in satellite_ids:
  143. satellite = satellites.get(sat_id)
  144. if not satellite or not satellite.is_connected:
  145. continue
  146. # Notification-Sound abspielen
  147. if self._notification_pcm:
  148. try:
  149. await satellite.say(self._notification_pcm)
  150. except Exception:
  151. pass
  152. # Text vorlesen
  153. if new_count == 1:
  154. text = "Du hast eine neue E-Mail."
  155. else:
  156. text = f"Du hast {new_count} neue E-Mails."
  157. try:
  158. await satellite.speak(text)
  159. except Exception as e:
  160. perror(f"Email-Benachrichtigung an '{sat_id}' fehlgeschlagen: {e}")
  161. # =========================================================================
  162. # Hilfsmethoden
  163. # =========================================================================
  164. def _get_accounts_for_request(self, data: IntentReceivedData) -> list[str]:
  165. """Ermittelt die Account-Keys fuer eine Anfrage."""
  166. if not self._mapper:
  167. return list(self._clients.keys())
  168. satellite_id = data.satellite_id or ""
  169. accounts = self._mapper.get_accounts_for_satellite(satellite_id)
  170. return accounts if accounts else list(self._clients.keys())
  171. async def _count_unseen_for_request(self, data: IntentReceivedData) -> int:
  172. """Zaehlt ungelesene E-Mails fuer alle Accounts eines Satellites."""
  173. total = 0
  174. for key in self._get_accounts_for_request(data):
  175. client = self._clients.get(key)
  176. if client:
  177. try:
  178. total += await client.count_unseen()
  179. except Exception as e:
  180. perror(f"Email '{key}' Zaehlung fehlgeschlagen: {e}")
  181. return total
  182. async def _fetch_recent_for_request(
  183. self, data: IntentReceivedData, count: int = 5
  184. ) -> list[EmailMessage]:
  185. """Holt die neuesten E-Mails fuer alle Accounts eines Satellites."""
  186. max_body = self.get_config_value("max_body_length", 500)
  187. all_messages: list[EmailMessage] = []
  188. for key in self._get_accounts_for_request(data):
  189. client = self._clients.get(key)
  190. if client:
  191. try:
  192. messages = await client.fetch_recent(count=count, max_body_length=max_body)
  193. all_messages.extend(messages)
  194. except Exception as e:
  195. perror(f"Email '{key}' Abruf fehlgeschlagen: {e}")
  196. # Nach Datum sortieren (neuste zuerst)
  197. all_messages.sort(key=lambda m: m.date or datetime.min, reverse=True)
  198. # Cachen fuer "vorlesen"
  199. sat_id = data.satellite_id or "standalone"
  200. self._last_fetched[sat_id] = all_messages
  201. return all_messages[:count]
  202. # =========================================================================
  203. # Intent-Handler
  204. # =========================================================================
  205. @intent("check_new_emails", description="Prueft ob neue E-Mails vorhanden sind")
  206. async def handle_check_new(self, data: IntentReceivedData) -> IntentResult:
  207. """Habe ich neue E-Mails?"""
  208. try:
  209. count = await self._count_unseen_for_request(data)
  210. except Exception as e:
  211. return IntentResult.failure(f"E-Mail-Pruefung fehlgeschlagen: {e}")
  212. if count == 0:
  213. return IntentResult.success_with_response("Du hast keine neuen E-Mails.")
  214. elif count == 1:
  215. return IntentResult.success_with_response("Du hast eine ungelesene E-Mail.")
  216. else:
  217. return IntentResult.success_with_response(
  218. f"Du hast {count} ungelesene E-Mails."
  219. )
  220. @intent("count_unread_emails", description="Zaehlt ungelesene E-Mails")
  221. async def handle_count_unread(self, data: IntentReceivedData) -> IntentResult:
  222. """Wie viele ungelesene E-Mails habe ich?"""
  223. try:
  224. count = await self._count_unseen_for_request(data)
  225. except Exception as e:
  226. return IntentResult.failure(f"E-Mail-Zaehlung fehlgeschlagen: {e}")
  227. if count == 0:
  228. return IntentResult.success_with_response(
  229. "Alle E-Mails sind gelesen. Du hast keine ungelesenen Nachrichten."
  230. )
  231. elif count == 1:
  232. return IntentResult.success_with_response(
  233. "Du hast eine ungelesene E-Mail."
  234. )
  235. else:
  236. return IntentResult.success_with_response(
  237. f"Du hast {count} ungelesene E-Mails."
  238. )
  239. @intent("read_newest_email", description="Liest Absender und Betreff der neusten E-Mail vor")
  240. async def handle_read_newest(self, data: IntentReceivedData) -> IntentResult:
  241. """Was ist meine neuste E-Mail?"""
  242. try:
  243. messages = await self._fetch_recent_for_request(data, count=1)
  244. except Exception as e:
  245. return IntentResult.failure(f"E-Mail-Abruf fehlgeschlagen: {e}")
  246. if not messages:
  247. return IntentResult.success_with_response("Du hast keine E-Mails.")
  248. msg = messages[0]
  249. response = f"Deine neuste E-Mail ist von {msg.sender_name}. "
  250. response += f"Betreff: {msg.subject}."
  251. if msg.date_str:
  252. response += f" Empfangen am {msg.date_str}."
  253. return IntentResult.success_with_response(response)
  254. @intent("read_email_body", description="Liest den Inhalt der letzten E-Mail vor")
  255. async def handle_read_body(self, data: IntentReceivedData) -> IntentResult:
  256. """Lies die E-Mail vor / Was steht drin?"""
  257. sat_id = data.satellite_id or "standalone"
  258. cached = self._last_fetched.get(sat_id, [])
  259. if not cached:
  260. # Noch keine E-Mails abgerufen — hole die neueste
  261. try:
  262. cached = await self._fetch_recent_for_request(data, count=1)
  263. except Exception as e:
  264. return IntentResult.failure(f"E-Mail-Abruf fehlgeschlagen: {e}")
  265. if not cached:
  266. return IntentResult.success_with_response("Keine E-Mail zum Vorlesen vorhanden.")
  267. msg = cached[0]
  268. if not msg.body_text:
  269. return IntentResult.success_with_response(
  270. f"Die E-Mail von {msg.sender_name} mit Betreff '{msg.subject}' hat keinen lesbaren Inhalt."
  271. )
  272. response = f"E-Mail von {msg.sender_name}. Betreff: {msg.subject}. "
  273. response += msg.body_text
  274. return IntentResult.success_with_response(response)
  275. @intent("list_recent_emails", description="Listet die letzten E-Mails auf")
  276. async def handle_list_recent(self, data: IntentReceivedData) -> IntentResult:
  277. """Zeig mir meine E-Mails."""
  278. try:
  279. messages = await self._fetch_recent_for_request(data, count=5)
  280. except Exception as e:
  281. return IntentResult.failure(f"E-Mail-Abruf fehlgeschlagen: {e}")
  282. if not messages:
  283. return IntentResult.success_with_response("Du hast keine E-Mails.")
  284. if len(messages) == 1:
  285. response = "Du hast eine E-Mail. "
  286. else:
  287. response = f"Hier sind deine letzten {len(messages)} E-Mails. "
  288. for i, msg in enumerate(messages, 1):
  289. unread = " neu," if msg.is_unread else ""
  290. response += f"{i}:{unread} von {msg.sender_name}, Betreff: {msg.subject}. "
  291. return IntentResult.success_with_response(response)
  292. # =========================================================================
  293. # Config-Tool Tabs
  294. # =========================================================================
  295. def get_config_tool_tabs(self) -> list[dict]:
  296. """Bietet keine zusaetzlichen Config-Tool-Tabs."""
  297. return []