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

580 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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