Files
aza/AzA march 2026/aza_empfang_desktop_core.py
2026-06-13 22:47:31 +02:00

1413 lines
55 KiB
Python

# -*- 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()