Files
aza/AzA march 2026/aza_empfang_incoming_popup.py

580 lines
19 KiB
Python
Raw Normal View History

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