403 lines
14 KiB
Python
403 lines
14 KiB
Python
# -*- 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
|