# -*- coding: utf-8 -*- """AzA Chat-only Desktop-Host — unsichtbarer Controller ohne AzA Office.""" from __future__ import annotations import os import sys import threading import time import unicodedata from typing import Optional import requests import tkinter as tk from tkinter import messagebox from aza_audit_log import log_event from aza_consent import has_valid_consent from aza_empfang_desktop_core import EmpfangDesktopCoreMixin, init_empfang_desktop_core_state from aza_empfang_host_poll import EmpfangHostPollMixin from aza_empfang_incoming_popup import empfang_popup_host from aza_persistence import load_autotext, load_user_profile, migrate_legacy_writable_data_if_needed, save_autotext, save_user_profile from openai_runtime_config import has_openai_api_key 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 _office_desktop_running() -> bool: if sys.platform != "win32": return False try: import subprocess as sp r = sp.run( ["tasklist", "/FI", "IMAGENAME eq aza_desktop.exe", "/NH"], capture_output=True, text=True, timeout=5, ) return "aza_desktop.exe" in (r.stdout or "").lower() except Exception: return False class ChatDesktopHost( tk.Tk, EmpfangDesktopCoreMixin, EmpfangHostPollMixin, ): """Unsichtbarer Tk-Host fuer Chat-only: Poll, Popup, Singleton Huelle/Kontakt.""" def __init__(self) -> None: super().__init__() self.withdraw() try: self.wm_attributes("-alpha", 0.0) except Exception: pass self.title("AzA Chat") self._shutdown_in_progress = False self._chat_host_mode = True self._autotext_data = load_autotext() try: migrate_legacy_writable_data_if_needed() except Exception: pass self._user_profile = load_user_profile() if not self._user_profile.get("name"): self._user_profile["name"] = "Benutzer" save_user_profile(self._user_profile) init_empfang_desktop_core_state(self) self._empfang_last_seen_ids: set = set() self._empfang_dismissed_notification_ids: set = set() self._empfang_native_popup_shown_ids: set = set() self._empfang_poll_autopopup_last_ts = 0.0 self._empfang_last_device_poll_wall = 0.0 self._empfang_shell_dm_open_pending = None empfang_popup_host(self) self.protocol("WM_DELETE_WINDOW", self.shutdown) self.after(800, self._bootstrap_chat_host) if not _office_desktop_running(): self.after(5000, self._empfang_background_poll) self.after(12000, self._chat_update_poll_loop) self.after(15000, self._start_empfang_presence_ping_loop) def _bootstrap_chat_host(self) -> None: try: self._sync_desktop_profile_from_empfang_metadata(force=False) except Exception: pass try: self._empfang_self_user_id_resolve_now() except Exception: pass self._prepare_empfang_prefs_for_webview() self._maybe_autostart_kontakt_panel() try: from desktop_update_check import prompt_chat_update_if_required, check_for_chat_updates prompt_chat_update_if_required(parent=self) check_for_chat_updates() except Exception: pass def _chat_update_poll_loop(self) -> None: if getattr(self, "_shutdown_in_progress", False): return try: from desktop_update_check import check_for_chat_updates, consume_chat_update_request_flag, manual_check_for_chat_updates check_for_chat_updates() if consume_chat_update_request_flag(): manual_check_for_chat_updates(parent=self) except Exception: pass try: self.after(3600000, self._chat_update_poll_loop) except Exception: pass def shutdown(self) -> None: self._shutdown_in_progress = True self._empfang_presence_stop = True try: self._shutdown_tracked_child_processes() except Exception: pass try: self.destroy() except Exception: pass def set_status(self, msg: str) -> None: pass def _debug_log(self, msg: str) -> None: pass @staticmethod def _clean_backend_value(value: str): if value is None: return None v = str(value).replace("\ufeff", "").strip(" \t\r\n") return v if v else None def _read_backend_value_file(self, filename: str) -> Optional[str]: search_dirs: list[str] = [] if getattr(sys, "frozen", False): search_dirs.append(os.path.dirname(sys.executable)) meipass = getattr(sys, "_MEIPASS", "") if meipass: search_dirs.append(meipass) search_dirs.append(os.path.dirname(os.path.abspath(__file__))) search_dirs.append(os.getcwd()) seen: set[str] = set() for base in search_dirs: if not base or base in seen: continue seen.add(base) p = os.path.join(base, filename) if not os.path.isfile(p): continue try: with open(p, "r", encoding="utf-8-sig") as f: for ln in f: s = ln.strip() if s and not s.startswith("#"): return self._clean_backend_value(s) except Exception: pass return None def get_backend_url(self): url = self._clean_backend_value(os.getenv("MEDWORK_BACKEND_URL")) if url: return url.rstrip("/") url = self._read_backend_value_file("backend_url.txt") if url: return url.rstrip("/") raise RuntimeError("Backend-URL fehlt.") def get_backend_token(self): token = self._read_backend_value_file("backend_token.txt") if token: return token tokens_env = os.getenv("MEDWORK_API_TOKENS") if tokens_env and tokens_env.strip(): token = self._clean_backend_value(tokens_env.split(",")[0].strip()) else: token = self._clean_backend_value(os.getenv("MEDWORK_API_TOKEN")) if token: return token raise RuntimeError("Backend-Token fehlt.") def get_practice_id(self): return (self._user_profile.get("practice_id") or "").strip() def _empfang_self_display_name(self) -> str: dn = (self._user_profile.get("empfang_display_name") or "").strip() if dn: return dn return (self._user_profile.get("name") or "").strip() def _empfang_self_user_id(self) -> str: return (self._user_profile.get("empfang_user_id") or "").strip() def _empfang_self_user_id_resolve_now(self, users_full: Optional[list] = None) -> str: my_dn = self._empfang_self_display_name() if not my_dn: return self._empfang_self_user_id() full = users_full if full is None: try: bu = self.get_backend_url() r = requests.get(f"{bu}/empfang/users", headers=self._empfang_headers(), timeout=5) if r.status_code == 200: full = (r.json() or {}).get("users_full") or [] except Exception: full = [] if not isinstance(full, list): return self._empfang_self_user_id() target = _empfang_identity_key(my_dn) candidates = [] for u in full: if not isinstance(u, dict): continue if _empfang_identity_key(u.get("display_name") or "") != target: continue uid = str(u.get("user_id") or "").strip() if uid: role = str(u.get("role") or "").strip().lower() status = str(u.get("status") or "active").strip().lower() candidates.append({"user_id": uid, "role": role, "status": status}) if not candidates: return self._empfang_self_user_id() role_rank = {"admin": 0, "arzt": 1, "empfang": 2, "mpa": 3} def _score(c): return (0 if c["status"] == "active" else 1, role_rank.get(c["role"], 9)) candidates.sort(key=_score) chosen = candidates[0]["user_id"] if self._empfang_self_user_id() != chosen: self._user_profile["empfang_user_id"] = chosen try: save_user_profile(self._user_profile) except Exception: pass return chosen def _empfang_headers(self) -> dict: hdrs = { "X-API-Token": self.get_backend_token(), "X-Practice-Id": self.get_practice_id(), } uid = self._empfang_self_user_id() if uid: hdrs["X-AzA-Empfang-User-Id"] = uid return hdrs def _prepare_empfang_prefs_for_webview(self) -> None: prefs = self._autotext_data.setdefault("empfang_prefs", {}) self._autotext_data["empfang_prefs"] = prefs try: save_autotext(self._autotext_data) except Exception: pass def _send_to_empfang(self) -> None: try: self._sync_desktop_profile_from_empfang_metadata(force=False) except Exception: pass try: self._empfang_self_user_id_resolve_now() except Exception: pass self._prepare_empfang_prefs_for_webview() self._empfang_open_webview_singleton(gui_parent=self, silent_status=True, offer_tk_on_shell_fail=False) def _empfang_schedule_context_autopopup(self, reason: str) -> None: return def _empfang_maybe_autopopup_from_poll(self, new_ids: set, msgs: list) -> None: return def _empfang_show_pending_device_notice(self, snap: dict) -> None: return def _sync_desktop_profile_from_empfang_metadata(self, force: bool = False) -> None: try: self._empfang_self_user_id_resolve_now() except Exception: pass def ensure_ready(self) -> bool: if has_openai_api_key() or os.getenv("MEDWORK_BACKEND_URL"): return True messagebox.showinfo( "KI-Verbindung", "Die KI-Verbindung ist noch nicht eingerichtet.\n" "Bitte OpenAI-Schlüssel oder Backend konfigurieren.", parent=empfang_popup_host(self), ) return False def _check_ai_consent(self) -> bool: try: uid = getattr(self, "_get_consent_user_id", lambda: "chat-host")() except Exception: uid = "chat-host" if has_valid_consent(uid): return True messagebox.showinfo("KI-Einwilligung", "Bitte KI-Einwilligung in AzA Office erteilen.", parent=empfang_popup_host(self)) return False def _get_consent_user_id(self) -> str: return (self.get_practice_id() or "chat-host").strip() or "chat-host" def _ensure_microphone_ready(self) -> bool: try: from aza_audio import check_microphone ok, msg = check_microphone() if not ok: messagebox.showwarning("Mikrofon", msg or "Mikrofon nicht verfügbar.", parent=empfang_popup_host(self)) return bool(ok) except Exception: return True def transcribe_wav(self, wav_path: str) -> str: uid = self._get_consent_user_id() if not has_valid_consent(uid): raise RuntimeError("KI-Einwilligung fehlt.") log_event("AI_TRANSCRIBE", uid) try: import basis14 return basis14.KGDesktopApp.transcribe_file_via_backend_with_fallback(self, wav_path) except Exception as exc: raise RuntimeError(str(exc)) from exc EMPFANG_PRESENCE_PING_INTERVAL_S = 28 def _start_empfang_presence_ping_loop(self) -> None: try: existing = getattr(self, "_empfang_presence_thread", None) if isinstance(existing, threading.Thread) and existing.is_alive(): return except Exception: pass self._empfang_presence_stop = False t = threading.Thread(target=self._empfang_presence_ping_loop_worker, name="aza-chat-presence", daemon=True) self._empfang_presence_thread = t t.start() def _empfang_presence_ping_loop_worker(self) -> None: interval = max(10, int(self.EMPFANG_PRESENCE_PING_INTERVAL_S)) while not getattr(self, "_empfang_presence_stop", False): try: bu = self.get_backend_url() requests.post(f"{bu}/empfang/presence/ping", headers=self._empfang_headers(), json={}, timeout=5) except Exception: pass for _ in range(interval): if getattr(self, "_empfang_presence_stop", False): return time.sleep(1) def try_acquire_chat_host_singleton() -> bool: if sys.platform != "win32": return True try: import ctypes from ctypes import wintypes kernel32 = ctypes.windll.kernel32 ERROR_ALREADY_EXISTS = 183 mutex = kernel32.CreateMutexW(None, False, "Global\\AZA_ChatDesktopHost_v1") if kernel32.GetLastError() == ERROR_ALREADY_EXISTS: return False return True except Exception: return True def focus_existing_kontakt_panel() -> bool: if sys.platform != "win32": return False try: helper = object.__new__(ChatDesktopHost) init_empfang_desktop_core_state(helper) EmpfangDesktopCoreMixin._kontakt_panel_try_bring_to_front(helper, 0) EmpfangDesktopCoreMixin._kontakt_panel_try_bring_to_front(helper, 400) EmpfangDesktopCoreMixin._empfang_shell_focus_existing_instances(helper) return True except Exception: return False