# -*- coding: utf-8 -*- """Desktop-Popup bei eingehender Empfangsnachricht (Tk, unabhaengig vom Hauptfenster).""" from __future__ import annotations import sys import threading import uuid from typing import Any, Optional import requests import tkinter as tk from aza_empfang_shell_surface import surface_indicates_active_direct_chat _AUTOCLOSE_POLL_MS = 1500 def empfang_main_window_usable(app: Any) -> bool: try: if not app.winfo_exists(): return False st = str(app.state() or "").strip().lower() if st in ("iconic", "withdrawn"): return False return bool(app.winfo_viewable()) except Exception: return False def empfang_shell_hwnd_minimized(app: Any) -> bool: if sys.platform != "win32": return False try: import ctypes hwnd = int(app._empfang_webview_resolve_shell_hwnd() or 0) if not hwnd: return False return bool(ctypes.windll.user32.IsIconic(hwnd)) except Exception: return False def should_suppress_incoming_popup(app: Any, peer_user_id: str) -> bool: """Kein Desktop-Popup, wenn passender Chat sichtbar/aktiv ist.""" puid = (peer_user_id or "").strip() if not puid: return False if not app._empfang_webview_subprocess_alive(): return False if empfang_shell_hwnd_minimized(app): return False return surface_indicates_active_direct_chat( puid, require_visible=True, require_not_minimized=True, ) def empfang_popup_host(app: Any) -> tk.Misc: """Unsichtbarer, immer gemappter Host — Popup bleibt bedienbar bei withdrawn Hauptfenster.""" host = getattr(app, "_empfang_alert_popup_host", None) try: if host is not None and host.winfo_exists(): return host except Exception: pass host = tk.Toplevel(app) try: host.overrideredirect(True) host.geometry("1x1+-200+-200") host.attributes("-alpha", 0.0) except Exception: pass try: host.wm_state("normal") host.deiconify() except Exception: pass setattr(app, "_empfang_alert_popup_host", host) return host def empfang_popup_parent(app: Any) -> tk.Misc: if empfang_main_window_usable(app): return app return empfang_popup_host(app) def insert_diktat_at_cursor(text_widget: tk.Text, txt: str) -> None: """Transkript an Cursor einfuegen; vorhandene Auswahl ersetzen.""" body = (txt or "").strip() if not body: return try: if text_widget.tag_ranges("sel"): text_widget.mark_set(tk.INSERT, tk.SEL_FIRST) text_widget.delete(tk.SEL_FIRST, tk.SEL_LAST) idx = text_widget.index(tk.INSERT) text_widget.insert(idx, body) end = text_widget.index(tk.INSERT) text_widget.mark_set(tk.INSERT, end) text_widget.see(end) text_widget.focus_set() except Exception: pass def send_dm_text_to_peer(app: Any, peer_user_id: str, text: str) -> tuple[bool, str]: body = (text or "").strip() if not body: return False, "Bitte Antworttext eingeben." pid = (app.get_practice_id() or "").strip() su = (app._empfang_self_user_id() or "").strip() if not su: su = (app._empfang_self_user_id_resolve_now() or "").strip() ru = (peer_user_id or "").strip() if not pid or not su or not ru: return False, "Technische Benutzer-ID oder Praxis fehlt." if su == ru: return False, "Selbstchat ist nicht erlaubt." payload = { "practice_id": pid, "sender_user_id": su, "recipient_user_id": ru, "text": body, "attachments": [], "client_msg_id": f"desk-popup-{uuid.uuid4().hex[:12]}", } try: r = requests.post( f"{app.get_backend_url()}/empfang/dm/send", json=payload, headers=app._empfang_headers(), timeout=(8, 30), ) if r.status_code == 200: return True, "" detail = "" try: j = r.json() detail = str(j.get("detail") or "") if isinstance(j, dict) else "" except Exception: detail = (r.text or "")[:120] return False, detail or f"Server-Fehler ({r.status_code})" except Exception as exc: return False, f"Netzwerkfehler: {type(exc).__name__}" def _raise_popup(app: Any, top: tk.Misc) -> None: try: from aza_ui_helpers import bring_tool_window_to_front, center_tool_window parent = empfang_popup_parent(app) top.update_idletasks() center_tool_window( top, 520, 460, parent=parent, vertical_center=True, bring_to_front=False ) bring_tool_window_to_front(top, flash_ms=1500) except Exception: app._empfang_native_alert_raise(top) def show_incoming_message_popup( app: Any, *, preview: str, sender_label: str, message_id: str, peer_user_id: str, peer_display_name: str, external_dm: bool = False, ) -> None: prev = ((preview or "").strip().replace("\n", " ").replace("\r", "")) if len(prev) > 160: prev = prev[:157] + "..." snd_lbl = ((sender_label or "Empfang").strip() or "Empfang") p_uid = (peer_user_id or "").strip() p_dn = (peer_display_name or snd_lbl.split("(", 1)[0].strip() or "").strip() mid = (message_id or "").strip() if mid and mid in getattr(app, "_empfang_dismissed_notification_ids", set()): return if should_suppress_incoming_popup(app, p_uid): return shown = getattr(app, "_empfang_native_popup_shown_ids", None) if not isinstance(shown, set): app._empfang_native_popup_shown_ids = set() shown = app._empfang_native_popup_shown_ids if mid and mid in shown: exist = getattr(app, "_empfang_native_alert_top", None) if exist is not None: try: if exist.winfo_exists(): _raise_popup(app, exist) return except Exception: pass exist = getattr(app, "_empfang_native_alert_top", None) if exist is not None: try: if exist.winfo_exists(): wdg = getattr(app, "_empfang_native_alert_widgets", None) if isinstance(wdg, dict): try: wdg["sender"].config(text=snd_lbl) wdg["preview"].config(text=prev if prev else "(…)") except Exception: pass setattr(app, "_empfang_native_alert_peer_uid", p_uid) setattr(app, "_empfang_native_alert_peer_dn", p_dn) setattr(app, "_empfang_native_alert_mid", mid) setattr(app, "_empfang_native_alert_external_dm", bool(external_dm)) _raise_popup(app, exist) return except Exception: pass top = tk.Toplevel(empfang_popup_parent(app)) setattr(app, "_empfang_native_alert_top", top) if mid: shown.add(mid) if len(shown) > 400: app._empfang_native_popup_shown_ids = set(list(shown)[-200:]) top.title("AzA Empfang – neue Nachricht") top.configure(bg="#eef3f8") top.resizable(False, False) try: top.deiconify() top.wm_state("normal") except Exception: pass if empfang_main_window_usable(app): try: top.transient(app) except Exception: pass header = tk.Frame(top, bg="#1a3550", padx=18, pady=12) header.pack(fill="x") tk.Label( header, text="Neue Empfangsnachricht", font=("Segoe UI", 13, "bold"), bg="#1a3550", fg="#ffffff", ).pack(anchor="w") lb_sender = tk.Label( header, text=snd_lbl, font=("Segoe UI", 10), bg="#1a3550", fg="#b8d4ea", ) lb_sender.pack(anchor="w", pady=(4, 0)) err_var = tk.StringVar(value="") lb_err = tk.Label( header, textvariable=err_var, font=("Segoe UI", 9), bg="#1a3550", fg="#ffb4b4", wraplength=440, justify="left", ) body = tk.Frame(top, bg="#eef3f8", padx=18, pady=14) body.pack(fill="both", expand=True) card = tk.Frame(body, bg="#ffffff", padx=12, pady=10, highlightbackground="#d8e3ee", highlightthickness=1) card.pack(fill="x", pady=(0, 10)) lb_prev = tk.Label( card, text=prev if prev else "(…)", font=("Segoe UI", 10), bg="#ffffff", fg="#2a4a62", wraplength=440, justify="left", anchor="w", ) lb_prev.pack(anchor="w", fill="x") tk.Label( body, text="Antwort", font=("Segoe UI", 9, "bold"), bg="#eef3f8", fg="#4a6278", ).pack(anchor="w", pady=(0, 4)) reply_txt = tk.Text( body, height=4, font=("Segoe UI", 10), wrap="word", relief="flat", highlightbackground="#c5d5e4", highlightthickness=1, padx=8, pady=6, ) reply_txt.pack(fill="x", pady=(0, 6)) status_var = tk.StringVar(value="") tk.Label( body, textvariable=status_var, font=("Segoe UI", 9), bg="#eef3f8", fg="#5B8DB3", ).pack(anchor="w") setattr( app, "_empfang_native_alert_widgets", {"sender": lb_sender, "preview": lb_prev, "reply": reply_txt, "status": status_var}, ) setattr(app, "_empfang_native_alert_peer_uid", p_uid) setattr(app, "_empfang_native_alert_peer_dn", p_dn) setattr(app, "_empfang_native_alert_mid", mid) setattr(app, "_empfang_native_alert_external_dm", bool(external_dm)) diktat_rec: list[Any] = [None] diktat_active = [False] send_inflight = [False] def _set_status(msg: str, *, is_error: bool = False): status_var.set(msg or "") if is_error and msg: err_var.set(msg) try: lb_err.pack(anchor="w", pady=(6, 0)) except Exception: pass else: err_var.set("") try: lb_err.pack_forget() except Exception: pass def _clear_refs(): setattr(app, "_empfang_native_alert_top", None) setattr(app, "_empfang_native_alert_widgets", None) def _finalize_diktat_then(fn): if not diktat_active[0]: fn() return _set_status("Diktat wird beendet …") try: rec = diktat_rec[0] if rec is None: diktat_active[0] = False fn() return def _worker(): safe_path = None err = "" txt = "" try: from aza_audio import persist_audio_safe wav_path = rec.stop_and_save_wav() try: safe_path = persist_audio_safe(wav_path) except Exception: safe_path = wav_path txt = app.transcribe_wav(safe_path) txt = app._diktat_apply_punctuation(txt) except Exception as exc: err = str(exc) app.after(0, lambda: _done(txt, err)) def _done(txt: str, err: str): diktat_active[0] = False diktat_rec[0] = None try: btn_dikt.config(text="Diktieren") except Exception: pass if err: _set_status("Diktat fehlgeschlagen.", is_error=True) elif txt: insert_diktat_at_cursor(reply_txt, txt) _set_status("Diktat eingefügt.") else: _set_status("") fn() threading.Thread(target=_worker, daemon=True).start() except Exception: diktat_active[0] = False fn() def _close(*, skip_ack: bool = False): def _do(): cur_mid = (getattr(app, "_empfang_native_alert_mid", "") or "").strip() if not skip_ack: ext_dm = bool(getattr(app, "_empfang_native_alert_external_dm", False)) app._empfang_quit_notification(cur_mid, external_dm=ext_dm) try: top.grab_release() except Exception: pass try: top.destroy() except Exception: pass _clear_refs() _finalize_diktat_then(_do) def _open_chat(): def _do(): puid = (getattr(app, "_empfang_native_alert_peer_uid", "") or "").strip() pdn = (getattr(app, "_empfang_native_alert_peer_dn", "") or "").strip() cur_mid = (getattr(app, "_empfang_native_alert_mid", "") or "").strip() ext_dm = bool(getattr(app, "_empfang_native_alert_external_dm", False)) app._empfang_quit_notification(cur_mid, external_dm=ext_dm) try: top.grab_release() except Exception: pass try: top.destroy() except Exception: pass _clear_refs() if puid: setattr( app, "_empfang_shell_dm_open_pending", {"peer_user_id": puid, "display_name": pdn, "message_id": cur_mid}, ) try: app._prepare_empfang_prefs_for_webview() except Exception: pass app._send_to_empfang() try: app._empfang_webview_schedule_focus_retries((0, 300, 900)) except Exception: pass _finalize_diktat_then(_do) def _send_reply(): if send_inflight[0]: return text = reply_txt.get("1.0", "end").strip() if not text: _set_status("Bitte Antworttext eingeben.", is_error=True) return puid = (getattr(app, "_empfang_native_alert_peer_uid", "") or "").strip() if not puid: _set_status("Absender-ID fehlt — bitte Chat öffnen.", is_error=True) return def _after_diktat(): send_inflight[0] = True _set_status("Sende …") try: from aza_ui_helpers import set_tool_action_button_enabled set_tool_action_button_enabled(btn_send, False) except Exception: pass def _worker(): ok, msg = send_dm_text_to_peer(app, puid, text) app.after(0, lambda: _done_send(ok, msg)) threading.Thread(target=_worker, daemon=True).start() def _done_send(ok: bool, msg: str): send_inflight[0] = False try: from aza_ui_helpers import set_tool_action_button_enabled set_tool_action_button_enabled(btn_send, True) except Exception: pass if ok: try: reply_txt.delete("1.0", "end") except Exception: pass cur_mid = (getattr(app, "_empfang_native_alert_mid", "") or "").strip() ext_dm = bool(getattr(app, "_empfang_native_alert_external_dm", False)) app._empfang_quit_notification(cur_mid, external_dm=ext_dm) _set_status("Gesendet.") try: top.after(350, lambda: _close(skip_ack=True)) except Exception: _close(skip_ack=True) else: _set_status(msg or "Senden fehlgeschlagen.", is_error=True) _finalize_diktat_then(_after_diktat) def _toggle_diktat(): if send_inflight[0]: return if not app.ensure_ready() or not app._check_ai_consent(): return if not diktat_rec[0]: from aza_audio import AudioRecorder diktat_rec[0] = AudioRecorder() rec = diktat_rec[0] if not diktat_active[0]: if not app._ensure_microphone_ready(): return try: rec.start() diktat_active[0] = True btn_dikt.config(text="Stoppen") _set_status("Aufnahme läuft …") except Exception as exc: _set_status(f"Aufnahme nicht möglich ({type(exc).__name__}).", is_error=True) else: diktat_active[0] = False btn_dikt.config(text="Diktieren") _set_status("Transkribiere …") def _worker(): safe_path = None err = "" txt = "" try: from aza_audio import persist_audio_safe wav_path = rec.stop_and_save_wav() try: safe_path = persist_audio_safe(wav_path) except Exception: safe_path = wav_path txt = app.transcribe_wav(safe_path) txt = app._diktat_apply_punctuation(txt) except Exception as exc: err = str(exc) app.after(0, lambda: _done(txt, err)) def _done(txt: str, err: str): diktat_rec[0] = None if err: _set_status("Diktat fehlgeschlagen.", is_error=True) elif txt: insert_diktat_at_cursor(reply_txt, txt) _set_status("Diktat eingefügt.") else: _set_status("") threading.Thread(target=_worker, daemon=True).start() bf = tk.Frame(body, bg="#eef3f8") bf.pack(fill="x", pady=(10, 0)) left = tk.Frame(bf, bg="#eef3f8") left.pack(side="left") right = tk.Frame(bf, bg="#eef3f8") right.pack(side="right") from aza_ui_helpers import create_tool_action_button btn_dikt = create_tool_action_button(left, "Diktieren", _toggle_diktat, kind="secondary") btn_dikt.pack(side="left", padx=(0, 8)) btn_send = create_tool_action_button(left, "Absenden", _send_reply, kind="primary") btn_send.pack(side="left") create_tool_action_button(right, "Chat öffnen", _open_chat, kind="secondary").pack(side="left", padx=(0, 8)) create_tool_action_button(right, "Schließen", _close, kind="secondary").pack(side="left") def _on_top_destroy(_ev=None): _clear_refs() try: top.protocol("WM_DELETE_WINDOW", _close) top.bind("", _on_top_destroy) except Exception: pass def _autoclose_tick(): try: if not top.winfo_exists(): return cur_puid = (getattr(app, "_empfang_native_alert_peer_uid", "") or "").strip() if should_suppress_incoming_popup(app, cur_puid): _close() return except Exception: pass try: top.after(_AUTOCLOSE_POLL_MS, _autoclose_tick) except Exception: pass _raise_popup(app, top) try: top.after(_AUTOCLOSE_POLL_MS, _autoclose_tick) except Exception: pass