# -*- coding: utf-8 -*- """Gemeinsame Empfang-/Chat-Desktop-Logik (Singleton Huelle + Kontaktpanel).""" from __future__ import annotations import hashlib import os import re import subprocess import sys import threading import time import ctypes from ctypes import wintypes from pathlib import Path from typing import Optional, Tuple from urllib.parse import quote import requests import tkinter as tk from tkinter import messagebox import unicodedata def _empfang_identity_key(label: str) -> str: t = (label or "").strip().lower() t = unicodedata.normalize("NFKD", t) return "".join(ch for ch in t if unicodedata.combining(ch) == 0) def init_empfang_desktop_core_state(host) -> None: host._empfang_webview_proc = None host._empfang_webview_last_child_pid = None host._kontakt_panel_proc = None host._kontakt_panel_last_child_pid = None host._kontakt_panel_inflight_until = 0.0 host._kontakt_panel_launch_ts = 0.0 host._kontakt_panel_launch_lock = threading.Lock() host._aza_tracked_external_pids = set() host._empfang_webview_launch_lock = threading.Lock() host._empfang_webview_launch_inflight_until = 0.0 host._empfang_webview_launch_ts = 0.0 class EmpfangDesktopCoreMixin: _EMPFANG_SHELL_TITLE_PREFIXES: Tuple[str, ...] = ( "AzA Chat", "AzA-Empfang", "AzA Empfang Chat", ) _KONTAKT_PANEL_TITLE_PREFIXES: Tuple[str, ...] = ("AzA Kontakte",) def _win32_top_level_usable(self, hwnd: int) -> bool: """Sichtbar oder minimiert (nicht versteckt) — fuer Singleton-Fokus.""" if sys.platform != "win32" or not int(hwnd or 0): return False try: user32 = ctypes.windll.user32 if not user32.IsWindow(hwnd): return False return bool(user32.IsWindowVisible(hwnd) or user32.IsIconic(hwnd)) except Exception: return False def _title_matches_prefixes(self, title: str, prefixes: Tuple[str, ...]) -> bool: t = title or "" return any(t == p or t.startswith(p) for p in prefixes if p) def _singleton_inflight_treat_as_alive( self, *, inflight_until: float, launch_ts: float, proc, last_child_pid: int, title_prefixes: Tuple[str, ...], empty_grace_s: float = 18.0, ) -> bool: """Inflight nur waehrend Fetch/Popen — nicht nach geschlossenem Fenster blockieren.""" now = time.time() if now >= float(inflight_until or 0.0): return False try: if proc is not None and proc.poll() is None: return True except Exception: pass lip = int(last_child_pid or 0) if lip and self._empfang_win_child_pid_alive(lip): return True try: if self._enum_visible_window_hwnds_for_prefixes(title_prefixes): return True except Exception: pass if proc is None and not lip and (now - float(launch_ts or 0.0)) < float(empty_grace_s or 18.0): return True return False def _tracked_child_hwnd_for_pid_win( self, target_pid: int, title_prefixes: Tuple[str, ...] ) -> int: """Top-Level-Fenster eines bekannten Kindprozesses (nur Titel-Praefixe).""" if sys.platform != "win32" or int(target_pid or 0) <= 0: return 0 try: user32 = ctypes.windll.user32 found: list[int] = [] @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) def _cb(hwnd, _lp): if not self._win32_top_level_usable(hwnd): return True p = wintypes.DWORD() user32.GetWindowThreadProcessId(hwnd, ctypes.byref(p)) if int(p.value) != int(target_pid): return True buf = ctypes.create_unicode_buffer(320) user32.GetWindowTextW(hwnd, buf, 320) t = buf.value or "" if self._title_matches_prefixes(t, title_prefixes): found.append(int(hwnd)) return False return True user32.EnumWindows(_cb, 0) return int(found[0]) if found else 0 except Exception: return 0 def _all_tracked_child_hwnds_for_pid_win( self, target_pid: int, title_prefixes: Tuple[str, ...] ) -> list[int]: """Alle nutzbaren Top-Level-Fenster eines Prozesses (Titel-Praefixe).""" if sys.platform != "win32" or int(target_pid or 0) <= 0: return [] try: user32 = ctypes.windll.user32 found: list[int] = [] @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) def _cb(hwnd, _lp): if not self._win32_top_level_usable(hwnd): return True p = wintypes.DWORD() user32.GetWindowThreadProcessId(hwnd, ctypes.byref(p)) if int(p.value) != int(target_pid): return True buf = ctypes.create_unicode_buffer(320) user32.GetWindowTextW(hwnd, buf, 320) t = buf.value or "" if self._title_matches_prefixes(t, title_prefixes): found.append(int(hwnd)) return True user32.EnumWindows(_cb, 0) out: list[int] = [] seen: set[int] = set() for h in found: if h not in seen: seen.add(h) out.append(h) return out except Exception: return [] def _post_wm_close_to_hwnd(self, hwnd: int) -> None: if sys.platform != "win32" or not int(hwnd or 0): return try: WM_CLOSE = 0x0010 ctypes.windll.user32.PostMessageW(int(hwnd), WM_CLOSE, 0, 0) except Exception: pass def _aza_track_external_pid(self, pid: int) -> None: try: p = int(pid or 0) if p > 0 and p != int(os.getpid()): self._aza_tracked_external_pids.add(p) except Exception: pass def _win32_bring_hwnd_to_front(self, hwnd: int) -> None: if sys.platform != "win32" or not int(hwnd or 0): return try: user32 = ctypes.windll.user32 SW_RESTORE = 9 user32.ShowWindow(int(hwnd), SW_RESTORE) user32.SetForegroundWindow(int(hwnd)) except Exception: pass def _enum_visible_window_hwnds_for_prefixes( self, title_prefixes: Tuple[str, ...] ) -> list[tuple[int, int]]: """(hwnd, pid) aller sichtbaren Top-Level-Fenster mit Titel-Praefix.""" if sys.platform != "win32": return [] try: user32 = ctypes.windll.user32 found: list[tuple[int, int]] = [] prefixes = tuple(p for p in title_prefixes if p) @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) def _cb(hwnd, _lp): if not self._win32_top_level_usable(hwnd): return True buf = ctypes.create_unicode_buffer(320) user32.GetWindowTextW(hwnd, buf, 320) t = buf.value or "" if not self._title_matches_prefixes(t, prefixes): return True p = wintypes.DWORD() user32.GetWindowThreadProcessId(hwnd, ctypes.byref(p)) found.append((int(hwnd), int(p.value))) return True user32.EnumWindows(_cb, 0) return found except Exception: return [] def _empfang_shell_refresh_tracked_pid_from_hwnd(self) -> bool: matches = self._enum_visible_window_hwnds_for_prefixes( self._EMPFANG_SHELL_TITLE_PREFIXES ) if not matches: return False prefer = 0 try: pop = getattr(self, "_empfang_webview_proc", None) if pop is not None and pop.poll() is None: prefer = int(pop.pid) except Exception: prefer = 0 lip = int(getattr(self, "_empfang_webview_last_child_pid", 0) or 0) pid_pick = 0 for _h, pid in matches: if prefer and pid == prefer: pid_pick = pid break if lip and pid == lip: pid_pick = pid break if not pid_pick: pid_pick = int(matches[0][1]) if pid_pick: self._empfang_webview_last_child_pid = pid_pick self._aza_track_external_pid(pid_pick) return True return False def _kontakt_panel_refresh_tracked_pid_from_hwnd(self) -> bool: matches = self._enum_visible_window_hwnds_for_prefixes( self._KONTAKT_PANEL_TITLE_PREFIXES ) if not matches: return False prefer = 0 try: pop = getattr(self, "_kontakt_panel_proc", None) if pop is not None and pop.poll() is None: prefer = int(pop.pid) except Exception: prefer = 0 lip = int(getattr(self, "_kontakt_panel_last_child_pid", 0) or 0) pid_pick = 0 for _h, pid in matches: if prefer and pid == prefer: pid_pick = pid break if lip and pid == lip: pid_pick = pid break if not pid_pick: pid_pick = int(matches[0][1]) if pid_pick: self._kontakt_panel_last_child_pid = pid_pick self._aza_track_external_pid(pid_pick) return True return False def _kontakt_panel_subprocess_alive(self) -> bool: try: proc = getattr(self, "_kontakt_panel_proc", None) if proc is not None and proc.poll() is None: try: self._aza_track_external_pid(int(proc.pid)) except Exception: pass return True if proc is not None and proc.poll() is not None: # PyInstaller-Bootloader beendet — Kind-PID per HWND nachziehen. self._kontakt_panel_proc = None except Exception: pass try: self._kontakt_panel_refresh_tracked_pid_from_hwnd() except Exception: pass lip = int(getattr(self, "_kontakt_panel_last_child_pid", 0) or 0) if lip and self._empfang_win_child_pid_alive(lip): return True infl = float(getattr(self, "_kontakt_panel_inflight_until", 0.0) or 0.0) if infl and time.time() < infl: if self._singleton_inflight_treat_as_alive( inflight_until=infl, launch_ts=float(getattr(self, "_kontakt_panel_launch_ts", 0.0) or 0.0), proc=getattr(self, "_kontakt_panel_proc", None), last_child_pid=lip, title_prefixes=self._KONTAKT_PANEL_TITLE_PREFIXES, empty_grace_s=6.0, ): return True self._kontakt_panel_inflight_until = 0.0 return False def _kontakt_panel_try_bring_to_front(self, delay_ms: int = 200) -> bool: if sys.platform != "win32": return False def _run(): try: self._kontakt_panel_refresh_tracked_pid_from_hwnd() lip = int(getattr(self, "_kontakt_panel_last_child_pid", 0) or 0) hwnd = 0 if lip: hwnd = self._tracked_child_hwnd_for_pid_win( lip, self._KONTAKT_PANEL_TITLE_PREFIXES ) if not hwnd: for h, _p in self._enum_visible_window_hwnds_for_prefixes( self._KONTAKT_PANEL_TITLE_PREFIXES ): hwnd = h break if hwnd: self._win32_bring_hwnd_to_front(hwnd) except Exception: pass try: self.after(max(0, int(delay_ms)), _run) return True except Exception: return False def _fetch_empfang_shell_session_dict(self) -> Tuple[Optional[dict], Optional[str]]: """POST /empfang/shell/session ohne UI. Rueckgabe: (payload, None) oder (None, kurze deutsch Fehlertext). Kein shell_token im Fehlertext. """ try: self._empfang_self_user_id_resolve_now() except Exception: pass try: bu = self.get_backend_url() except Exception as e: return None, f"Backend nicht erreichbar: {e}" hdrs = dict(self._empfang_headers()) if not hdrs.get("X-Practice-Id"): return None, ( "practice_id fehlt im Profil.\nBitte Praxis-Verknuepfung / Empfang-Provisioning." ) if not hdrs.get("X-AzA-Empfang-User-Id"): return None, ( "Keine serverseitige Empfang-user_id.\nBitte erst »An Empfang senden« oeffnen, " "Benutzer laden, dann erneut versuchen." ) try: r = requests.post( f"{bu}/empfang/shell/session", headers=hdrs, json={}, timeout=20, ) except Exception as e: return None, f"Netzwerkfehler: {e}" if r.status_code != 200: try: err = r.json() detail = err.get("detail") if isinstance(err, dict) else None if isinstance(detail, list) and detail: detail = str(detail[0]) if not isinstance(detail, str): detail = None except Exception: detail = None if not detail: detail = ((r.text or "").strip()[:260] if (r.text or "").strip() else None) or f"HTTP {r.status_code}" return None, f"Server ({r.status_code}): {detail}" try: return r.json(), None except Exception: return None, "Ungueltige JSON-Antwort." def _empfang_shell_diag_log(self, **fields) -> None: """Kurzdiagnose fuer Empfang-Web-Shell-Start (keine Tokens/Passwoerter).""" try: parts = [f"[EMPFANG_SHELL_START] source={fields.get('source', '?')}"] order = ( "action", "runner", "url_path", "has_token", "child_pid", "popen_pid", "existing_proc_alive", "hwnd_found", "skip_reason", "exe_hint", ) for k in order: if k in fields and fields[k] is not None: parts.append(f"{k}={fields[k]}") self._debug_log(" ".join(parts)) except Exception: pass def _empfang_win_child_pid_alive(self, pid: int) -> bool: if sys.platform != "win32" or int(pid or 0) <= 0: return False try: kernel32 = ctypes.windll.kernel32 PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, int(pid)) if not h: return False kernel32.CloseHandle(h) return True except Exception: return False def _empfang_webview_hwnd_for_pid_win(self, target_pid: int) -> int: """Sichtbares Top-Level-Fenster des Prozesses mit AzA-Empfang-Titel (kein globales FindWindow).""" if sys.platform != "win32" or int(target_pid or 0) <= 0: return 0 try: user32 = ctypes.windll.user32 found: list[int] = [] @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) def _cb(hwnd, _lp): if not self._win32_top_level_usable(hwnd): return True p = wintypes.DWORD() user32.GetWindowThreadProcessId(hwnd, ctypes.byref(p)) if int(p.value) != int(target_pid): return True buf = ctypes.create_unicode_buffer(320) user32.GetWindowTextW(hwnd, buf, 320) t = buf.value or "" if self._title_matches_prefixes(t, self._EMPFANG_SHELL_TITLE_PREFIXES): found.append(int(hwnd)) return False return True user32.EnumWindows(_cb, 0) return int(found[0]) if found else 0 except Exception: return 0 def _empfang_webview_resolve_shell_hwnd(self) -> int: """HWND nur fuer unseren bekannten Shell-Kindprozess (Popen oder gespeicherte PID).""" self._empfang_webview_prune_dead_proc() pid_cand = 0 p = getattr(self, "_empfang_webview_proc", None) if p is not None: try: if p.poll() is None: pid_cand = int(p.pid) except Exception: pass if not pid_cand: lip = int(getattr(self, "_empfang_webview_last_child_pid", 0) or 0) if lip and self._empfang_win_child_pid_alive(lip): pid_cand = lip if pid_cand: return self._empfang_webview_hwnd_for_pid_win(pid_cand) return 0 def _empfang_webview_prune_dead_proc(self) -> None: p = getattr(self, "_empfang_webview_proc", None) if p is None: return try: if p.poll() is not None: try: self._empfang_shell_refresh_tracked_pid_from_hwnd() except Exception: pass lip = int(getattr(self, "_empfang_webview_last_child_pid", 0) or 0) if lip and self._empfang_win_child_pid_alive(lip): self._empfang_webview_proc = None return self._empfang_webview_proc = None try: dead_pid = int(p.pid) if dead_pid and int( getattr(self, "_empfang_webview_last_child_pid", 0) or 0 ) == dead_pid: self._empfang_webview_last_child_pid = None except Exception: pass except Exception: self._empfang_webview_proc = None def _empfang_webview_subprocess_alive(self) -> bool: self._empfang_webview_prune_dead_proc() p = getattr(self, "_empfang_webview_proc", None) if p is not None: try: if p.poll() is None: self._aza_track_external_pid(int(p.pid)) return True except Exception: self._empfang_webview_proc = None try: self._empfang_shell_refresh_tracked_pid_from_hwnd() except Exception: pass lip = int(getattr(self, "_empfang_webview_last_child_pid", 0) or 0) if lip and self._empfang_win_child_pid_alive(lip): return True infl = float(getattr(self, "_empfang_webview_launch_inflight_until", 0) or 0) if infl and time.time() < infl: if self._singleton_inflight_treat_as_alive( inflight_until=infl, launch_ts=float(getattr(self, "_empfang_webview_launch_ts", 0.0) or 0.0), proc=getattr(self, "_empfang_webview_proc", None), last_child_pid=lip, title_prefixes=self._EMPFANG_SHELL_TITLE_PREFIXES, ): return True self._empfang_webview_launch_inflight_until = 0.0 return False def _empfang_webview_try_bring_to_front(self, delay_ms: int = 950) -> bool: """Vordergrund erst verzoegert anfordern (Main-Thread), kein sofortiges Win32 nach Popen.""" def _run(): try: hwnd = self._empfang_webview_resolve_shell_hwnd() if not hwnd: lip = int(getattr(self, "_empfang_webview_last_child_pid", 0) or 0) for h, pid in self._enum_visible_window_hwnds_for_prefixes( self._EMPFANG_SHELL_TITLE_PREFIXES ): if not lip or pid == lip: hwnd = h break if not hwnd: return user32 = ctypes.windll.user32 SW_RESTORE = 9 user32.ShowWindow(hwnd, SW_RESTORE) user32.SetForegroundWindow(hwnd) except Exception: pass if sys.platform != "win32": return False try: self.after(max(0, int(delay_ms)), _run) return True except Exception: return False def _empfang_webview_schedule_focus_retries( self, delays_ms: Tuple[int, ...] = (0, 300, 900) ) -> None: """Mehrere zeitversetzte Win32-Vordergrund-Versuche fuer die Empfang-WebView.""" if sys.platform != "win32": return for d in delays_ms: try: self._empfang_webview_try_bring_to_front(delay_ms=int(d)) except Exception: pass def _prepare_empfang_prefs_for_webview(self) -> None: """Schreibt KG-/Patient-Kontext in empfang_prefs (ohne Chattexte).""" prefs = self._autotext_data.setdefault("empfang_prefs", {}) extracted = self._extract_kg_sections() prefill = getattr(self, "_empfang_prefill", None) self._empfang_prefill = None if prefill: doc_type = prefill.get("doc_type", "") doc_text = prefill.get("text", "") if doc_type == "Kostengutsprache": extracted["therapieplan"] = doc_text pat = "" kg = self.txt_output.get("1.0", "end").strip() for line in kg.splitlines()[:50]: raw = line.strip() if not raw: continue low = raw.lower() if low.startswith("patient") and ":" in raw: cand = raw.split(":", 1)[1].strip() if cand: pat = cand break m = re.match(r"(?i)^patient\s*[:\.]?\s*(.+)$", raw) if m: cand = m.group(1).strip() if cand: pat = cand break if pat: prefs["last_patient"] = pat try: self._empfang_shell_section_snapshot = { "therapieplan": (extracted.get("therapieplan") or "").strip(), "procedere": (extracted.get("procedere") or "").strip(), "medikamente": (extracted.get("medikamente") or "").strip(), } except Exception: self._empfang_shell_section_snapshot = None self._autotext_data["empfang_prefs"] = prefs try: save_autotext(self._autotext_data) except Exception: pass def _empfang_normalize_section_body_for_shell(self, raw: str, *, kind: str) -> str: """Nur Textkoerper Therapie bzw. Procedere fuer WebView/RAM-Kontext (keine ganze KG). Beispiel-Struktur (manueller Test aus AzA KG):: Diagnose … Therapie … Kryotherapie … Procedere … Folgetermin … Ergebnis: Therapiekarte ohne Diagnose-/Procedere-Anteile; Procederekarte ohne Therapie. """ s = (raw or "").strip() if not s: return "" if len(s) > 62000 or s.count("\n") > 260: return "" lines = list(s.splitlines()) headings_ther = frozenset({ "therapie", "therapieplan", "behandlung", "behandlungsplan", "therapeutisches vorgehen", "aktuelle therapie", "therapieempfehlung", }) headings_proc = frozenset({ "procedere", "prozedere", "weiteres procedere", "weiteres vorgehen", "nächste schritte", "nachste schritte", "nachfolgendes procedere", "empfohlenes procedere", }) drops = headings_ther if kind == "ther" else headings_proc def _normalize_heading_line(one: str) -> str: t = (one or "").strip() while t.startswith("*"): t = t.lstrip("*").strip() while t.endswith("*"): t = t.rstrip("*").strip() t = re.sub(r"^[\-\#\u2022\u25CF\u25AA\s]+", "", t) t = re.sub(r"^\d+[\.\)]\s*", "", t) return t.lower().rstrip(":").rstrip(".").strip() while lines: fl_norm = _normalize_heading_line(lines[0]) if fl_norm in drops: lines = lines[1:] continue if kind == "ther" and fl_norm.startswith("therapie"): lines = lines[1:] continue if kind == "proc" and fl_norm.startswith("procedere"): lines = lines[1:] continue break out = "\n".join(lines).strip() if not out: return "" first_line = out.splitlines()[0].strip() h0 = _normalize_heading_line(first_line) if h0 in ("diagnose", "diagnosen"): return "" if kind == "ther" and h0 in headings_proc: return "" if kind == "proc" and h0 in headings_ther: return "" return out def _empfang_desktop_shell_context_payload(self) -> dict: snap = getattr(self, "_empfang_shell_section_snapshot", None) if isinstance(snap, dict): raw_ther = snap.get("therapieplan") or "" raw_proc = snap.get("procedere") or "" else: ex = self._extract_kg_sections() raw_ther = ex.get("therapieplan") or "" raw_proc = ex.get("procedere") or "" prefs = self._autotext_data.get("empfang_prefs") or {} ther = self._empfang_normalize_section_body_for_shell(raw_ther, kind="ther") proc = self._empfang_normalize_section_body_for_shell(raw_proc, kind="proc") dm_uid = "" dm_dn = "" dm_mid = "" pend = getattr(self, "_empfang_shell_dm_open_pending", None) if isinstance(pend, dict): dm_uid = str(pend.get("peer_user_id") or "").strip()[:64] dm_dn = str(pend.get("display_name") or "").strip()[:200] dm_mid = str(pend.get("message_id") or "").strip()[:64] return { "therapy_text": ther, "procedure_text": proc, "therapy_autocopy": bool(prefs.get("auto_copy_ther", False)), "procedure_autocopy": bool(prefs.get("auto_copy_proc", False)), "dm_open_peer_user_id": dm_uid, "dm_open_display_name": dm_dn, "dm_open_msg_id": dm_mid, } def _post_empfang_shell_context_safe(self) -> bool: """POST /empfang/shell/context — nur Groessen in Logs (Server), kein Klartext.""" try: bu = self.get_backend_url() payload = self._empfang_desktop_shell_context_payload() r = requests.post( f"{bu}/empfang/shell/context", headers=self._empfang_headers(), json=payload, timeout=15, ) ok = r.status_code == 200 if ok: setattr(self, "_empfang_shell_dm_open_pending", None) return ok except Exception: return False def _empfang_incoming_sender_is_other(self, m: dict) -> bool: """True wenn Nachricht nicht vom eigenen Empfang-Anzeigenamen stammt.""" me_raw = (self._empfang_self_display_name() or "").strip() if not me_raw: return True me_k = _empfang_identity_key(me_raw) snd = (m.get("absender") or "") core = (snd or "").split("(", 1)[0].strip() return _empfang_identity_key(core) != me_k def _empfang_native_alert_message_targets_me(self, m: dict) -> bool: """True, wenn die Nachricht fuer den aktuellen Desktop-Benutzer relevant ist.""" ex = m.get("extras") if isinstance(m.get("extras"), dict) else {} me = (self._empfang_self_user_id() or "").strip() if not me: return True ru = str(ex.get("recipient_user_id") or "").strip() rlist = ex.get("recipients") is_multi = isinstance(rlist, list) and len(rlist) >= 2 rcpt_raw = (ex.get("recipient") or "").strip().lower() broadcast = not rcpt_raw or rcpt_raw in ("alle", "all", "allgemein") if ru and not broadcast and not is_multi: if ru != me: return False return True def _empfang_native_alert_is_incoming(self, m: dict) -> bool: """Eingehende fremde Nachricht (nicht eigenes Senden), fuer Desktop-Popup.""" if m.get("status") != "offen": return False if not self._empfang_native_alert_message_targets_me(m): return False ex = m.get("extras") if isinstance(m.get("extras"), dict) else {} if bool(ex.get("chat_ack")): return False me = (self._empfang_self_user_id() or "").strip() su = str(ex.get("sender_user_id") or "").strip() if me and su and su == me: return False if me and su and su != me: return True return self._empfang_incoming_sender_is_other(m) def _empfang_native_alert_raise(self, top: tk.Misc) -> None: try: if not top.winfo_exists(): return except Exception: return try: top.deiconify() except Exception: pass try: top.wm_state("normal") except Exception: pass try: top.attributes("-topmost", True) except Exception: pass try: top.lift() except Exception: pass try: top.focus_force() except Exception: pass try: self.after(600, lambda t=top: self._empfang_native_alert_clear_topmost(t)) except Exception: pass def _empfang_native_alert_clear_topmost(self, top: tk.Misc) -> None: try: if top.winfo_exists(): top.attributes("-topmost", False) except Exception: pass def _empfang_offer_tk_fallback(self, parent: tk.Misc, msg: str) -> None: """Kein produktiver Tkinter-Ersatz fuer die Web-Empfangshuelle. Nur wenn AZA_EMPFANG_ALLOW_TK_FALLBACK=1 und nicht frozen: optional Dev-Oeffnung der Legacy-Maske. """ dev_tk = ( not getattr(sys, "frozen", False) and str(os.getenv("AZA_EMPFANG_ALLOW_TK_FALLBACK", "")).strip() == "1" ) if dev_tk: if messagebox.askyesno( "Empfang-H\u00fclle", (msg or "WebView-H\u00fclle nicht gestartet.") + "\n\n" "Klassische Tkinter-Empfangsmaske stattdessen \u00f6ffnen? (nur Entwicklung)", parent=parent, ): try: self._open_empfang_tk_dialog_legacy() except Exception as exc: messagebox.showerror("Empfang", str(exc), parent=parent) return if getattr(sys, "frozen", False): text = ( "AZA Chat ist nicht installiert oder wurde nicht gefunden.\n\n" "Bitte f\u00fchren Sie den Setup-Assistenten erneut aus und stellen Sie sicher, " "dass die Komponente \u00abAZA Chat\u00bb aktiviert ist " "(bei AZA Office standardm\u00e4ssig dabei).\n\n" "Wenn Sie Hilfe ben\u00f6tigen, wenden Sie sich bitte an den Support." ) else: m = (msg or "Die Empfang-H\u00fclle konnte nicht gestartet werden.").strip() text = ( m + "\n\n" + "Bitte pr\u00fcfen Sie, ob AZA_EmpfangShell.exe gebaut ist (dist-Ordner) " + "oder ob aza_empfang_webview.py im Projekt liegt." ) try: messagebox.showerror("AZA Chat", text, parent=parent) except Exception: pass def _empfang_open_webview_singleton( self, *, gui_parent: Optional[tk.Misc] = None, silent_status: bool = False, offer_tk_on_shell_fail: bool = True, ) -> None: """Startet h\u00f6chstens eine WebView-H\u00fclle; l\u00e4uft sie schon, Fokus statt neuem Prozess. Schutz gegen doppelte Starts: in-memory Popen-Ref UND Window-HWND zaehlen als alive. Zusaetzlich Launch-in-flight-Lock fuer ~12 s, damit waehrend HTTP-Shell-Session-Fetch keine zweite Empfang-Auto-Popup denselben Pfad nochmal startet (Token 1x verbrauchbar). """ _par = gui_parent if gui_parent is not None else self self._empfang_webview_prune_dead_proc() existing_alive = self._empfang_webview_subprocess_alive() hwnd_now = self._empfang_webview_resolve_shell_hwnd() if existing_alive else 0 self._empfang_shell_diag_log( source="main", action=("focus_existing" if existing_alive else "spawn"), existing_proc_alive=existing_alive, hwnd_found=bool(hwnd_now), child_pid=getattr(self, "_empfang_webview_last_child_pid", None), popen_pid=( getattr(self._empfang_webview_proc, "pid", None) if getattr(self, "_empfang_webview_proc", None) is not None else None ), ) if existing_alive: try: self._prepare_empfang_prefs_for_webview() self._post_empfang_shell_context_safe() except Exception: pass self._empfang_shell_focus_existing_instances() if not silent_status: try: self.set_status("Empfangs-Chat geöffnet.") except Exception: pass try: self._maybe_autostart_kontakt_panel() except Exception: pass return if not self._empfang_webview_launch_lock.acquire(blocking=False): self._empfang_shell_diag_log( source="main", action="skip", skip_reason="launch_lock_busy", ) self._empfang_shell_focus_existing_instances() return try: now_ts = time.time() infl = float(self._empfang_webview_launch_inflight_until or 0.0) if infl and now_ts < infl: if self._singleton_inflight_treat_as_alive( inflight_until=infl, launch_ts=float(getattr(self, "_empfang_webview_launch_ts", 0.0) or 0.0), proc=getattr(self, "_empfang_webview_proc", None), last_child_pid=int(getattr(self, "_empfang_webview_last_child_pid", 0) or 0), title_prefixes=self._EMPFANG_SHELL_TITLE_PREFIXES, ): self._empfang_shell_diag_log( source="main", action="skip", skip_reason="launch_inflight", ) self._empfang_shell_focus_existing_instances() return self._empfang_webview_launch_inflight_until = 0.0 self._empfang_webview_launch_ts = now_ts self._empfang_webview_launch_inflight_until = now_ts + 12.0 finally: try: self._empfang_webview_launch_lock.release() except Exception: pass def _worker(): try: self._post_empfang_shell_context_safe() except Exception: pass data, err = self._fetch_empfang_shell_session_dict() if err: try: self._empfang_webview_launch_inflight_until = time.time() + 2.0 except Exception: pass self._empfang_shell_diag_log( source="main", action="abort", has_token=False, skip_reason="shell_session_error", ) def _fail_shell(): if silent_status: try: self.set_status( "Empfang (Auto): Shell-Session nicht m\u00f6glich — bitte sp\u00e4ter erneut.", ) except Exception: pass return if offer_tk_on_shell_fail: self._empfang_offer_tk_fallback(_par, err) else: messagebox.showerror("Empfang-H\u00fclle", err, parent=_par) self.after(0, _fail_shell) return shell_tok = (data.get("shell_token") if isinstance(data, dict) else None) or "" shell_tok = str(shell_tok).strip() if not shell_tok: try: self._empfang_webview_launch_inflight_until = time.time() + 2.0 except Exception: pass self._empfang_shell_diag_log( source="main", action="abort", has_token=False, skip_reason="no_shell_token", ) def _no_tok(): msg = "Antwort ohne shell_token vom Server." if silent_status: try: self.set_status("Empfang (Auto): " + msg) except Exception: pass return if offer_tk_on_shell_fail: self._empfang_offer_tk_fallback(_par, msg) else: messagebox.showerror("Empfang-H\u00fclle", msg, parent=_par) self.after(0, _no_tok) return try: base = self._empfang_shell_frontend_base_url() except Exception as ex: try: self._empfang_webview_launch_inflight_until = time.time() + 2.0 except Exception: pass def _be(): if silent_status: self.set_status(f"Empfang: {ex}") elif offer_tk_on_shell_fail: self._empfang_offer_tk_fallback(_par, str(ex)) else: messagebox.showerror("Empfang-H\u00fclle", str(ex), parent=_par) self.after(0, _be) return launch_url = ( f"{base}/empfang/shell/launch?token={quote(shell_tok, safe='')}" f"&target=empfang_chat_shell" ) exe_bundle = self._empfang_shell_webview_bundle_path() starter = self._empfang_shell_webview_script_path() if exe_bundle is not None: cmd = [str(exe_bundle), launch_url] cwd = str(exe_bundle.parent) runner = "exe" exe_hint = str(exe_bundle) elif starter.is_file(): cmd = [sys.executable, str(starter), launch_url] cwd = str(starter.parent) runner = "python" exe_hint = str(starter) else: self._empfang_shell_diag_log( source="main", action="abort", runner="missing", url_path="/empfang/shell/launch?token=(redacted)", has_token=True, ) def _st(): msg = f"Empfang-Starter nicht gefunden (weder AZA_EmpfangShell.exe noch {starter.name})." if silent_status: try: self.set_status("Empfang: Starter fehlt.") except Exception: pass return if offer_tk_on_shell_fail: self._empfang_offer_tk_fallback(_par, msg) else: if getattr(sys, "frozen", False): messagebox.showerror( "AZA Chat", "AZA Chat ist nicht installiert oder wurde nicht gefunden.\n\n" "Bitte f\u00fchren Sie den Setup-Assistenten erneut aus und aktivieren Sie " "die Komponente \u00abAZA Chat\u00bb.\n\n" "Bei Fragen wenden Sie sich bitte an den Support.", parent=_par, ) else: messagebox.showerror( "Empfang-H\u00fclle", msg + "\n\n(Bitte build_exe.ps1 ausfuehren oder Projekt pruefen.)", parent=_par, ) self.after(0, _st) return try: popen_kw = {"cwd": cwd, "close_fds": (sys.platform != "win32"), "env": self._empfang_child_env()} if sys.platform == "win32": _pflags = int(getattr(subprocess, "CREATE_NO_WINDOW", 0) or 0) _pflags |= int(getattr(subprocess, "DETACHED_PROCESS", 0) or 0) _pflags |= int(getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) or 0) if _pflags: popen_kw["creationflags"] = _pflags popen_kw["stdin"] = subprocess.DEVNULL popen_kw["stdout"] = subprocess.DEVNULL popen_kw["stderr"] = subprocess.DEVNULL self._empfang_webview_proc = subprocess.Popen(cmd, **popen_kw) try: self._empfang_webview_last_child_pid = int(self._empfang_webview_proc.pid) self._aza_track_external_pid(self._empfang_webview_last_child_pid) except Exception: self._empfang_webview_last_child_pid = None for _d in (800, 2000, 4000): self.after( _d, lambda: self._empfang_shell_refresh_tracked_pid_from_hwnd(), ) try: self._empfang_webview_launch_inflight_until = time.time() + 2.0 except Exception: pass self._empfang_shell_diag_log( source="main", action="spawned", runner=runner, url_path="/empfang/shell/launch?token=(redacted)", has_token=True, popen_pid=getattr(self._empfang_webview_proc, "pid", None), child_pid=self._empfang_webview_last_child_pid, exe_hint=exe_hint[:220] if exe_hint else "", ) except Exception as exc: try: self._empfang_webview_launch_inflight_until = time.time() + 2.0 except Exception: pass self._empfang_shell_diag_log( source="main", action="abort", runner=runner, skip_reason="popen_failed", ) def _pex(): msg = f"Fenster-Start fehlgeschlagen:\n{exc}\n(pywebview installiert?)" if silent_status: try: self.set_status("Empfang (Auto): Fensterstart fehlgeschlagen.") except Exception: pass return if offer_tk_on_shell_fail: self._empfang_offer_tk_fallback(_par, msg) else: messagebox.showerror("Empfang-H\u00fclle", msg, parent=_par) self.after(0, _pex) return def _ok_stat(): if not silent_status: try: self.set_status( "Empfang-Web-H\u00fclle gestartet (eigenes Fenster, Shell-Session).", ) except Exception: pass self.after(0, _ok_stat) try: self.after(1500, self._maybe_autostart_kontakt_panel) except Exception: pass threading.Thread(target=_worker, daemon=True).start() def _empfang_schedule_context_autopopup(self, reason: str) -> None: """Bei aktivem Chat-Empfang: WebView bei neuem Diktat-/KG-Kontext (entprellt).""" if not self._autotext_data.get("empfang_auto_open", False): return now = time.time() if now - float(getattr(self, "_empfang_ctx_autopopup_last_ts", 0.0) or 0.0) < 6.0: return tr = self.txt_transcript.get("1.0", "end").strip() kg = self.txt_output.get("1.0", "end").strip() if reason == "transcript": if len(tr) < 80: return elif reason == "kg_ready": if not kg.strip(): return else: return sig_src = f"{reason}|{tr[:3000]}|{kg[:3000]}" sig = hashlib.sha256(sig_src.encode("utf-8", errors="ignore")).hexdigest()[:24] if sig == getattr(self, "_empfang_ctx_autopopup_last_sig", ""): return self._empfang_ctx_autopopup_last_sig = sig self._empfang_ctx_autopopup_last_ts = now self._prepare_empfang_prefs_for_webview() self.after( 120, lambda: self._empfang_open_webview_singleton( gui_parent=self, silent_status=True, offer_tk_on_shell_fail=False, ), ) def _empfang_maybe_autopopup_from_poll(self, new_ids: set, msgs: list) -> None: """Bei aktivem Chat-Empfang: WebView bei neuer fremder Nachricht (wie Toast-Logik, entprellt).""" if not self._autotext_data.get("empfang_auto_open", False): return if not new_ids: return new_ids_nm = {str(x) for x in new_ids} now = time.time() if now - float(getattr(self, "_empfang_poll_autopopup_last_ts", 0.0) or 0.0) < 4.0: return candidates = [] for m in msgs: if not isinstance(m, dict): continue mid = m.get("id") if mid is None: continue sid = str(mid) if sid not in new_ids_nm: continue if m.get("status") != "offen": continue if not self._empfang_native_alert_is_incoming(m): continue candidates.append(sid) if not candidates: return trigger_id = None for sid in sorted(candidates): if sid in self._empfang_auto_popup_seen_ids: continue trigger_id = sid break if trigger_id is None: return self._empfang_auto_popup_seen_ids.add(trigger_id) if len(self._empfang_auto_popup_seen_ids) > 400: self._empfang_auto_popup_seen_ids = set( list(self._empfang_auto_popup_seen_ids)[-200:] ) self._empfang_poll_autopopup_last_ts = now # Autom. Vollhülle-Öffnung deaktiviert: das native Benachrichtigungs-Popup # (_show_empfang_chat_message_alert) wird bereits separat gezeigt und # öffnet den Chat gezielt mit dem richtigen Absender wenn gewünscht. # Die große Hülle nicht automatisch öffnen — Benutzer entscheidet via Popup. def _empfang_shell_frontend_base_url(self) -> str: """Origin fuer Shell-Launch: muss /empfang/shell/ ausliefern (Cookie-Host = Session-Host). Wenn AZA_EMPFANG_WEB_BASE gesetzt ist: dieser Host — muss dann auch die API-/shell Routen erreichen.""" pub = self._clean_backend_value(os.getenv("AZA_EMPFANG_WEB_BASE")) if pub: return pub.rstrip("/") if os.environ.get("AZA_DOKU_PROMPT_TEST", "").strip().lower() in ("1", "true", "yes"): try: from aza_empfang_test_html_proxy import test_proxy_base_url proxy = test_proxy_base_url() if proxy: return proxy.rstrip("/") except Exception: pass return self.get_backend_url().rstrip("/") def _empfang_child_env(self) -> dict[str, str]: """Proxy-/Test-Env explizit an Shell-Subprozesse weitergeben.""" env = os.environ.copy() for key in ( "AZA_DOKU_PROMPT_TEST", "AZA_EMPFANG_WEB_BASE", "AZA_EMPFANG_CHAT_SHELL_URL", "AZA_EMPFANG_TEST_PROXY_PORT", "AZA_EMPFANG_TEST_UPSTREAM", ): val = os.getenv(key) if val is not None and str(val).strip() != "": env[key] = str(val).strip() return env def _empfang_shell_webview_script_path(self) -> Path: """Separater Starter als subprocess (pywebview, kein Tk-Blockierung).""" return Path(__file__).resolve().parent / "aza_empfang_webview.py" def _kontakt_panel_starter(self) -> Optional[list]: """Starter-Kommando fuer das AzA Kontakt-Panel (EXE bevorzugt, sonst Python-Skript). Rueckgabe: cmd-Prefix-Liste OHNE URL (URL wird vom Aufrufer angehaengt) oder None, wenn weder Bundle-EXE noch Python-Skript gefunden werden. """ candidates: list[Path] = [] try: if getattr(sys, "frozen", False): _exe_dir = Path(sys.executable).resolve().parent candidates.append(_exe_dir / "AZA_KontaktPanel.exe") candidates.append(_exe_dir / "_internal" / "AZA_KontaktPanel.exe") meip = getattr(sys, "_MEIPASS", "") if meip: candidates.append(Path(meip) / "AZA_KontaktPanel.exe") except Exception: pass root = Path(__file__).resolve().parent candidates.append(root / "AZA_KontaktPanel.exe") candidates.append(root / "dist" / "AZA_KontaktPanel.exe") for exe in candidates: try: if exe.is_file(): return [str(exe)] except Exception: continue script = root / "aza_kontakt_panel.py" if script.is_file(): return [sys.executable, str(script)] return None def _maybe_autostart_kontakt_panel(self) -> None: """Startet das AzA Kontakt-Panel genau einmal zusammen mit dem Chat. - Singleton: laeuft bereits ein Panel-Prozess, passiert nichts. - Login-Handoff: derselbe sichere Einmal-Token wie die Empfang-Huelle (``_fetch_empfang_shell_session_dict`` -> ``/empfang/shell/launch``), gebunden an practice_id/user_id, kurzlebig, einmalig. Kein Passwort. - Nicht-blockierend (Daemon-Thread); jeder Fehler wird verschluckt und beeinflusst den Hauptchat-Start nicht. """ if self._kontakt_panel_subprocess_alive(): try: from aza_empfang_shell_surface import touch_shell_peer_refresh_signal touch_shell_peer_refresh_signal(source="kontakt_bring") except Exception: pass self._kontakt_panel_try_bring_to_front(0) self._kontakt_panel_try_bring_to_front(500) return if not self._kontakt_panel_launch_lock.acquire(blocking=False): self._kontakt_panel_try_bring_to_front(0) return try: now = time.time() kinfl = float(getattr(self, "_kontakt_panel_inflight_until", 0.0) or 0.0) if kinfl and now < kinfl: if self._singleton_inflight_treat_as_alive( inflight_until=kinfl, launch_ts=float(getattr(self, "_kontakt_panel_launch_ts", 0.0) or 0.0), proc=getattr(self, "_kontakt_panel_proc", None), last_child_pid=int(getattr(self, "_kontakt_panel_last_child_pid", 0) or 0), title_prefixes=self._KONTAKT_PANEL_TITLE_PREFIXES, empty_grace_s=6.0, ): self._kontakt_panel_try_bring_to_front(0) return self._kontakt_panel_inflight_until = 0.0 self._kontakt_panel_launch_ts = now self._kontakt_panel_inflight_until = now + 12.0 finally: try: self._kontakt_panel_launch_lock.release() except Exception: pass starter = self._kontakt_panel_starter() if not starter: return def _worker() -> None: spawned = False try: data, err = self._fetch_empfang_shell_session_dict() if err or not isinstance(data, dict): return shell_tok = str(data.get("shell_token") or "").strip() if not shell_tok: return base = self._empfang_shell_frontend_base_url() launch_url = ( f"{base}/empfang/shell/launch?token={quote(shell_tok, safe='')}" f"&target=kontakt_panel" ) cmd = list(starter) + [launch_url] popen_kw = { "cwd": str(Path(starter[-1]).resolve().parent), "close_fds": (sys.platform != "win32"), "env": self._empfang_child_env(), } if sys.platform == "win32": _pflags = int(getattr(subprocess, "CREATE_NO_WINDOW", 0) or 0) _pflags |= int(getattr(subprocess, "DETACHED_PROCESS", 0) or 0) _pflags |= int(getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) or 0) if _pflags: popen_kw["creationflags"] = _pflags popen_kw["stdin"] = subprocess.DEVNULL popen_kw["stdout"] = subprocess.DEVNULL popen_kw["stderr"] = subprocess.DEVNULL self._kontakt_panel_proc = subprocess.Popen(cmd, **popen_kw) spawned = True try: self._kontakt_panel_last_child_pid = int(self._kontakt_panel_proc.pid) self._aza_track_external_pid(self._kontakt_panel_last_child_pid) except Exception: self._kontakt_panel_last_child_pid = None for _d in (800, 2000, 4000): self.after( _d, lambda: self._kontakt_panel_refresh_tracked_pid_from_hwnd(), ) self._kontakt_panel_inflight_until = time.time() + 2.0 except Exception: pass finally: if not spawned: try: self._kontakt_panel_inflight_until = 0.0 except Exception: pass threading.Thread(target=_worker, daemon=True).start()