580 lines
19 KiB
Python
580 lines
19 KiB
Python
# -*- 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
|