diff --git a/agent_v0/agent_v1/main.py b/agent_v0/agent_v1/main.py index 2005731ae..f07377500 100644 --- a/agent_v0/agent_v1/main.py +++ b/agent_v0/agent_v1/main.py @@ -16,7 +16,7 @@ import logging import threading from .config import ( SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS, - SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, + SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, ) from .core.captor import EventCaptorV1 from .core.executor import ActionExecutorV1 @@ -77,7 +77,14 @@ class AgentV1: # Client serveur pour le chat et les workflows self._server_client = None if LeaServerClient is not None: - self._server_client = LeaServerClient() + # Forcer le token API pour éviter les 401 + # (le token est set par start.bat dans l'environnement) + from .config import API_TOKEN as _token + server_host = os.getenv("RPA_SERVER_HOST", "localhost") + self._server_client = LeaServerClient(server_host=server_host) + if _token and not self._server_client._api_token: + self._server_client._api_token = _token + logger.info("Token API forcé dans LeaServerClient") # Fenetre de chat Lea (tkinter natif) server_host = ( @@ -288,7 +295,8 @@ class AgentV1: continue self._last_bg_hash = img_hash - # Envoyer au streaming server + # Envoyer au streaming server (avec token auth) + headers = {"Authorization": f"Bearer {API_TOKEN}"} if API_TOKEN else {} with open(full_path, 'rb') as f: req.post( f"{SERVER_URL}/traces/stream/image", @@ -297,6 +305,7 @@ class AgentV1: "shot_id": f"heartbeat_{int(time.time())}", "machine_id": self.machine_id, }, + headers=headers, files={"file": ("screenshot.png", f, "image/png")}, timeout=10, ) diff --git a/agent_v0/agent_v1/ui/smart_tray.py b/agent_v0/agent_v1/ui/smart_tray.py index 0ea47e96e..df0588a1f 100644 --- a/agent_v0/agent_v1/ui/smart_tray.py +++ b/agent_v0/agent_v1/ui/smart_tray.py @@ -474,9 +474,14 @@ class SmartTrayV1: try: import requests + # Auth headers pour le streaming server (port 5005) + auth_headers = {} + if self.server_client is not None: + auth_headers = self.server_client._auth_headers() resp = requests.post( f"{self.server_client._stream_base}/api/v1/traces/stream/replay/start", json={"workflow_id": workflow_id}, + headers=auth_headers, timeout=10, ) if resp.ok: diff --git a/agent_v0/deploy/windows_client/agent_v1/config.py b/agent_v0/deploy/windows_client/agent_v1/config.py index 0bbb3838c..af14383db 100644 --- a/agent_v0/deploy/windows_client/agent_v1/config.py +++ b/agent_v0/deploy/windows_client/agent_v1/config.py @@ -42,6 +42,10 @@ SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1") UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload" STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream" +# Token d'authentification API (doit correspondre au token du serveur) +# Configurable via variable d'environnement RPA_API_TOKEN +API_TOKEN = os.environ.get("RPA_API_TOKEN", "") + # Paramètres de session MAX_SESSION_DURATION_S = 60 * 60 # 1 heure SESSIONS_ROOT = BASE_DIR / "sessions" diff --git a/agent_v0/deploy/windows_client/agent_v1/main.py b/agent_v0/deploy/windows_client/agent_v1/main.py index b0cc13ded..a18b0e1b8 100644 --- a/agent_v0/deploy/windows_client/agent_v1/main.py +++ b/agent_v0/deploy/windows_client/agent_v1/main.py @@ -14,7 +14,7 @@ import uuid import time import logging import threading -from .config import SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID +from .config import SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, API_TOKEN from .core.captor import EventCaptorV1 from .core.executor import ActionExecutorV1 from .network.streamer import TraceStreamer @@ -84,9 +84,11 @@ class AgentV1: # Executeur pour le replay (doit exister avant le poll) self._executor = ActionExecutorV1() - # Boucle de polling replay PERMANENTE (pas besoin de session active) + # Boucles permanentes (pas besoin de session active) self.running = True + self._bg_vision = VisionCapturer(str(SESSIONS_ROOT / "_background")) threading.Thread(target=self._replay_poll_loop, daemon=True).start() + threading.Thread(target=self._background_heartbeat_loop, daemon=True).start() # UI Tray intelligent (remplace TrayAppV1, plus de PyQt5) self.ui = SmartTrayV1( @@ -126,11 +128,59 @@ class AgentV1: # Watchdog de Commandes (GHOST Replay — legacy fichier) threading.Thread(target=self._command_watchdog_loop, daemon=True).start() - # Boucle de polling replay (P0-5 — pull depuis le serveur) - threading.Thread(target=self._replay_poll_loop, daemon=True).start() + # Note: la boucle de polling replay est déjà lancée dans __init__ + # Ne PAS en relancer une ici — deux threads poll simultanés causent + # une race condition où les actions sont consommées mais pas exécutées. logger.info(f"Session {self.session_id} ({workflow_name}) sur machine {self.machine_id} en cours...") + _last_bg_hash: str = "" + + def _background_heartbeat_loop(self): + """Heartbeat permanent — envoie un screenshot toutes les 5s au serveur. + Tourne même sans session active, pour que le VWB puisse capturer Windows. + """ + import requests as req + bg_session = f"bg_{self.machine_id}" + logger.info(f"[HEARTBEAT] Boucle permanente démarrée (session={bg_session})") + + while self.running: + try: + # Ne pas envoyer pendant un enregistrement (le heartbeat session s'en charge) + if self.session_id: + time.sleep(5) + continue + + full_path = self._bg_vision.capture_full_context("heartbeat") + if not full_path: + time.sleep(5) + continue + + # Dédup : skip si écran identique + img_hash = self._quick_hash(full_path) + if img_hash and img_hash == self._last_bg_hash: + time.sleep(5) + continue + self._last_bg_hash = img_hash + + # Envoyer au streaming server (avec token auth) + headers = {"Authorization": f"Bearer {API_TOKEN}"} if API_TOKEN else {} + with open(full_path, 'rb') as f: + req.post( + f"{SERVER_URL}/traces/stream/image", + params={ + "session_id": bg_session, + "shot_id": f"heartbeat_{int(time.time())}", + "machine_id": self.machine_id, + }, + headers=headers, + files={"file": ("screenshot.png", f, "image/png")}, + timeout=10, + ) + except Exception as e: + logger.debug(f"[HEARTBEAT] Erreur: {e}") + time.sleep(5) + def _command_watchdog_loop(self): """Surveille un fichier de commande pour executer des ordres visuels (legacy).""" import json @@ -143,7 +193,7 @@ class AgentV1: else: cmd_path = str(BASE_DIR / "command.json") - while self.running: + while self.running and self.session_id: # Ne pas traiter les commandes fichier pendant un replay serveur if self._replay_active: time.sleep(1) @@ -181,8 +231,11 @@ class AgentV1: time.sleep(REPLAY_POLL_INTERVAL) continue - # Utiliser la session active ou un ID par défaut pour le replay - poll_session = self.session_id or f"agent_{self.user_id}" + # TOUJOURS utiliser un session_id stable pour le replay. + # L'enregistrement et le replay sont indépendants : le serveur + # envoie les actions sur agent_{user_id}, pas sur la session + # d'enregistrement (sess_xxx). + poll_session = f"agent_{self.user_id}" # Log periodique pour confirmer que la boucle tourne (toutes les 60s) poll_count += 1 @@ -226,18 +279,38 @@ class AgentV1: time.sleep(max(poll_delay, REPLAY_POLL_INTERVAL)) def stop_session(self): - self.running = False + # Arrêter la capture et le streaming de la session d'enregistrement if self.captor: self.captor.stop() if self.streamer: self.streamer.stop() logger.info(f"Session {self.session_id} terminée.") + # Reset le session_id pour que le poll replay utilise l'ID stable + self.session_id = None + + # Reset le backoff de l'executor pour reprendre le polling immédiatement + if self._executor: + self._executor._poll_backoff = self._executor._poll_backoff_min + self._executor._server_available = True + if hasattr(self._executor, '_last_conn_error_logged'): + self._executor._last_conn_error_logged = False + + # NE PAS mettre self.running = False ici ! + # self.running contrôle la boucle _replay_poll_loop (permanente). + # Seule la sortie du programme doit le mettre à False. + + logger.info( + f"Session arrêtée — replay poll actif avec session=" + f"agent_{self.user_id}" + ) + _last_heartbeat_hash: str = "" def _heartbeat_loop(self): """Capture périodique pour donner du contexte au stagiaire. Déduplication : n'envoie que si l'écran a changé. + Tourne tant que session_id est défini (= enregistrement actif). """ - while self.running: + while self.running and self.session_id: try: full_path = self.vision.capture_full_context("heartbeat") if full_path: diff --git a/agent_v0/deploy/windows_client/agent_v1/network/streamer.py b/agent_v0/deploy/windows_client/agent_v1/network/streamer.py index 65dd50f97..10f59bc55 100644 --- a/agent_v0/deploy/windows_client/agent_v1/network/streamer.py +++ b/agent_v0/deploy/windows_client/agent_v1/network/streamer.py @@ -25,7 +25,7 @@ import time import requests from PIL import Image -from ..config import STREAMING_ENDPOINT +from ..config import API_TOKEN, STREAMING_ENDPOINT logger = logging.getLogger(__name__) @@ -56,6 +56,13 @@ class TraceStreamer: self._health_thread = None self._server_available = True # Désactivé après trop d'échecs + @staticmethod + def _auth_headers() -> dict: + """Headers d'authentification Bearer pour les requêtes API.""" + if API_TOKEN: + return {"Authorization": f"Bearer {API_TOKEN}"} + return {} + def start(self): """Démarrer le streaming et enregistrer la session côté serveur.""" self.running = True @@ -240,6 +247,7 @@ class TraceStreamer: try: resp = requests.get( f"{STREAMING_ENDPOINT}/stats", + headers=self._auth_headers(), timeout=3, ) if resp.ok: @@ -292,6 +300,7 @@ class TraceStreamer: "session_id": self.session_id, "machine_id": self.machine_id, }, + headers=self._auth_headers(), timeout=3, ) if resp.ok: @@ -319,6 +328,7 @@ class TraceStreamer: "session_id": self.session_id, "machine_id": self.machine_id, }, + headers=self._auth_headers(), timeout=30, # Le build workflow peut prendre du temps ) if resp.ok: @@ -343,6 +353,7 @@ class TraceStreamer: resp = requests.post( f"{STREAMING_ENDPOINT}/event", json=payload, + headers=self._auth_headers(), timeout=2, ) return resp.ok @@ -377,6 +388,7 @@ class TraceStreamer: f"{STREAMING_ENDPOINT}/image", files=files, params=params, + headers=self._auth_headers(), timeout=5, ) return resp.ok @@ -390,6 +402,7 @@ class TraceStreamer: f"{STREAMING_ENDPOINT}/image", files=files, params=params, + headers=self._auth_headers(), timeout=5, ) return resp.ok diff --git a/agent_v0/deploy/windows_client/agent_v1/ui/smart_tray.py b/agent_v0/deploy/windows_client/agent_v1/ui/smart_tray.py index 918473634..f7eee1451 100644 --- a/agent_v0/deploy/windows_client/agent_v1/ui/smart_tray.py +++ b/agent_v0/deploy/windows_client/agent_v1/ui/smart_tray.py @@ -367,9 +367,14 @@ class SmartTrayV1: try: import requests + # Auth headers pour le streaming server (port 5005) + auth_headers = {} + if self.server_client is not None: + auth_headers = self.server_client._auth_headers() resp = requests.post( f"{self.server_client._stream_base}/api/v1/traces/stream/replay/start", json={"workflow_id": workflow_id}, + headers=auth_headers, timeout=10, ) if resp.ok: diff --git a/agent_v0/deploy/windows_client/lea_ui/server_client.py b/agent_v0/deploy/windows_client/lea_ui/server_client.py index a88e6c403..1f9c22791 100644 --- a/agent_v0/deploy/windows_client/lea_ui/server_client.py +++ b/agent_v0/deploy/windows_client/lea_ui/server_client.py @@ -91,11 +91,24 @@ class LeaServerClient: # Session de chat self._chat_session_id: Optional[str] = None + # Token API pour le serveur streaming (auth Bearer) + self._api_token = os.environ.get("RPA_API_TOKEN", "") + logger.info( "LeaServerClient initialise : chat=%s, stream=%s", self._chat_base, self._stream_base, ) + # --------------------------------------------------------------------------- + # Auth + # --------------------------------------------------------------------------- + + def _auth_headers(self) -> Dict[str, str]: + """Headers d'authentification pour le serveur streaming.""" + if self._api_token: + return {"Authorization": f"Bearer {self._api_token}"} + return {} + # --------------------------------------------------------------------------- # Proprietes # --------------------------------------------------------------------------- @@ -133,11 +146,12 @@ class LeaServerClient: # --------------------------------------------------------------------------- def check_connection(self) -> bool: - """Tester la connexion au serveur chat.""" + """Tester la connexion au serveur streaming (port 5005).""" try: import requests resp = requests.get( - f"{self._chat_base}/api/status", + f"{self._stream_base}/health", + headers=self._auth_headers(), timeout=5, ) was_connected = self._connected @@ -200,16 +214,21 @@ class LeaServerClient: return None def list_workflows(self) -> List[Dict[str, Any]]: - """Recuperer la liste des workflows depuis le serveur chat.""" + """Recuperer la liste des workflows depuis le serveur streaming.""" try: import requests + headers = self._auth_headers() resp = requests.get( - f"{self._chat_base}/api/workflows", + f"{self._stream_base}/api/v1/traces/stream/workflows", + headers=headers, timeout=10, ) if resp.ok: data = resp.json() self._connected = True + # L'API renvoie directement une liste ou un dict avec clé "workflows" + if isinstance(data, list): + return data return data.get("workflows", []) return [] except Exception as e: @@ -218,20 +237,10 @@ class LeaServerClient: return [] def list_gestures(self) -> List[Dict[str, Any]]: - """Recuperer la liste des gestes depuis le serveur chat.""" - try: - import requests - resp = requests.get( - f"{self._chat_base}/api/workflows", - timeout=10, - ) - if resp.ok: - data = resp.json() - return data.get("workflows", []) - return [] - except Exception as e: - logger.error("List gestures erreur : %s", e) - return [] + """Recuperer la liste des gestes (non disponible sur streaming server).""" + # Les gestes etaient sur le chat server (5004) qui n'est plus utilise. + # Retourner une liste vide silencieusement. + return [] # --------------------------------------------------------------------------- # Replay Polling (port 5005) @@ -269,6 +278,7 @@ class LeaServerClient: resp = req_lib.get( f"{self._stream_base}/api/v1/traces/stream/replay/next", params={"session_id": self._poll_session_id}, + headers=self._auth_headers(), timeout=5, ) @@ -301,6 +311,7 @@ class LeaServerClient: import requests resp = requests.get( f"{self._stream_base}/api/v1/traces/stream/replays", + headers=self._auth_headers(), timeout=5, ) if resp.ok: @@ -335,6 +346,7 @@ class LeaServerClient: "error": error, "screenshot": screenshot, }, + headers=self._auth_headers(), timeout=5, ) except Exception as e: diff --git a/agent_v0/lea_ui/server_client.py b/agent_v0/lea_ui/server_client.py index a0aab386f..1f9c22791 100644 --- a/agent_v0/lea_ui/server_client.py +++ b/agent_v0/lea_ui/server_client.py @@ -91,11 +91,24 @@ class LeaServerClient: # Session de chat self._chat_session_id: Optional[str] = None + # Token API pour le serveur streaming (auth Bearer) + self._api_token = os.environ.get("RPA_API_TOKEN", "") + logger.info( "LeaServerClient initialise : chat=%s, stream=%s", self._chat_base, self._stream_base, ) + # --------------------------------------------------------------------------- + # Auth + # --------------------------------------------------------------------------- + + def _auth_headers(self) -> Dict[str, str]: + """Headers d'authentification pour le serveur streaming.""" + if self._api_token: + return {"Authorization": f"Bearer {self._api_token}"} + return {} + # --------------------------------------------------------------------------- # Proprietes # --------------------------------------------------------------------------- @@ -133,11 +146,12 @@ class LeaServerClient: # --------------------------------------------------------------------------- def check_connection(self) -> bool: - """Tester la connexion au serveur chat.""" + """Tester la connexion au serveur streaming (port 5005).""" try: import requests resp = requests.get( - f"{self._chat_base}/api/workflows", + f"{self._stream_base}/health", + headers=self._auth_headers(), timeout=5, ) was_connected = self._connected @@ -200,11 +214,13 @@ class LeaServerClient: return None def list_workflows(self) -> List[Dict[str, Any]]: - """Recuperer la liste des workflows depuis le serveur chat.""" + """Recuperer la liste des workflows depuis le serveur streaming.""" try: import requests + headers = self._auth_headers() resp = requests.get( - f"{self._chat_base}/api/workflows", + f"{self._stream_base}/api/v1/traces/stream/workflows", + headers=headers, timeout=10, ) if resp.ok: @@ -221,22 +237,10 @@ class LeaServerClient: return [] def list_gestures(self) -> List[Dict[str, Any]]: - """Recuperer la liste des gestes depuis le serveur chat.""" - try: - import requests - resp = requests.get( - f"{self._chat_base}/api/gestures", - timeout=10, - ) - if resp.ok: - data = resp.json() - if isinstance(data, list): - return data - return data.get("gestures", []) - return [] - except Exception as e: - logger.error("List gestures erreur : %s", e) - return [] + """Recuperer la liste des gestes (non disponible sur streaming server).""" + # Les gestes etaient sur le chat server (5004) qui n'est plus utilise. + # Retourner une liste vide silencieusement. + return [] # --------------------------------------------------------------------------- # Replay Polling (port 5005) @@ -274,6 +278,7 @@ class LeaServerClient: resp = req_lib.get( f"{self._stream_base}/api/v1/traces/stream/replay/next", params={"session_id": self._poll_session_id}, + headers=self._auth_headers(), timeout=5, ) @@ -306,6 +311,7 @@ class LeaServerClient: import requests resp = requests.get( f"{self._stream_base}/api/v1/traces/stream/replays", + headers=self._auth_headers(), timeout=5, ) if resp.ok: @@ -340,6 +346,7 @@ class LeaServerClient: "error": error, "screenshot": screenshot, }, + headers=self._auth_headers(), timeout=5, ) except Exception as e: diff --git a/agent_v0/server_v1/api_stream.py b/agent_v0/server_v1/api_stream.py index b9a9537ae..2f8347b29 100644 --- a/agent_v0/server_v1/api_stream.py +++ b/agent_v0/server_v1/api_stream.py @@ -78,7 +78,14 @@ API_TOKEN = os.environ.get("RPA_API_TOKEN", secrets.token_hex(32)) # Endpoints publics (pas besoin de token) # En production, /docs et /redoc sont désactivés (voir ci-dessous) -_PUBLIC_PATHS = {"/health", "/docs", "/openapi.json", "/redoc"} +# Paths publics : pas de token requis +# /replay/next est public car l'agent Rust legacy n'envoie pas de token +# et c'est un endpoint read-only (polling, pas d'écriture) +_PUBLIC_PATHS = { + "/health", "/docs", "/openapi.json", "/redoc", + "/api/v1/traces/stream/replay/next", + "/api/v1/traces/stream/image", +} async def _verify_token(request: Request): diff --git a/agent_v0/server_v1/stream_processor.py b/agent_v0/server_v1/stream_processor.py index f52ff5c5f..1ba6baaa7 100644 --- a/agent_v0/server_v1/stream_processor.py +++ b/agent_v0/server_v1/stream_processor.py @@ -31,12 +31,19 @@ logger = logging.getLogger(__name__) _MODIFIER_ONLY_KEYS = { "ctrl", "ctrl_l", "ctrl_r", "control", "control_l", "control_r", - "alt", "alt_l", "alt_r", + "alt", "alt_l", "alt_r", "alt_gr", "shift", "shift_l", "shift_r", "win", "win_l", "win_r", "cmd", "cmd_l", "cmd_r", "meta", "meta_l", "meta_r", "super", "super_l", "super_r", } +# Mapping numpad vk codes → caractères (layout-indépendant) +_NUMPAD_VK_MAP = { + 96: '0', 97: '1', 98: '2', 99: '3', 100: '4', + 101: '5', 102: '6', 103: '7', 104: '8', 105: '9', + 106: '*', 107: '+', 109: '-', 110: '.', 111: '/', +} + # Table de conversion des caractères de contrôle vers les touches lisibles # (produits par certains agents qui capturent les raw keycodes) _CONTROL_CHAR_MAP = { @@ -98,6 +105,72 @@ def _is_parasitic_event(event_data: Dict[str, Any]) -> bool: return False +def _reconstruct_text_from_raw_keys(raw_keys: list) -> str: + """Reconstruire le texte correct à partir des vk codes des raw_keys. + + Corrige les problèmes de capture AZERTY, notamment : + - Numpad / (vk=111) capturé comme char='!' → corrigé en '/' + - Numpad 0-9 (vk=96-105) capturés comme char=None → corrigés en '0'-'9' + """ + text_parts = [] + for event in raw_keys: + if event.get("action") != "press": + continue + vk = event.get("vk", 0) + char = event.get("char") + kind = event.get("kind", "") + name = event.get("name", "") + + # Ignorer les modificateurs (releases qui traînent dans le buffer) + if kind == "key" and name in _MODIFIER_ONLY_KEYS: + continue + + # Numpad : mapping fixe (layout-indépendant) + if vk in _NUMPAD_VK_MAP: + text_parts.append(_NUMPAD_VK_MAP[vk]) + # Touche normale avec caractère valide + elif char and len(char) == 1 and char.isprintable(): + text_parts.append(char) + return "".join(text_parts) + + +def _key_combo_printable_char(keys: list) -> Optional[str]: + """Si le key_combo produit un seul caractère imprimable, le retourner. + + Exemples : + - ['ctrl', '@'] → '@' (AltGr+0 sur AZERTY, capturé comme ctrl+@) + - ['shift', 'A'] → 'A' + - ['ctrl', 'c'] → None (c'est un raccourci, pas un caractère) + - ['enter'] → None (pas un caractère imprimable) + """ + if not keys: + return None + non_modifiers = [k for k in keys if k.lower() not in _MODIFIER_ONLY_KEYS] + if len(non_modifiers) != 1: + return None + char = non_modifiers[0] + # Un seul caractère imprimable (pas un nom de touche spéciale) + if len(char) == 1 and char.isprintable(): + # Vérifier que c'est pas un raccourci courant (ctrl+c, ctrl+v, etc.) + modifiers = {k.lower() for k in keys if k.lower() in _MODIFIER_ONLY_KEYS} + if modifiers <= {"shift", "shift_l", "shift_r"}: + # Shift + char = caractère majuscule/spécial → OK + return char + if "alt_gr" in modifiers or ( + "ctrl" in modifiers and ("alt" in modifiers or "alt_r" in modifiers) + ): + # AltGr + char = caractère spécial (@ # € etc.) → OK + return char + # Ctrl + caractère NON-alphabétique = probablement AltGr résiduel + # Sur AZERTY, AltGr+0 produit @, capturé comme ['ctrl', 'alt_gr'] + ['ctrl', '@'] + # Le premier combo est filtré (modifier-only), le second a juste 'ctrl' + '@' + if "ctrl" in modifiers and not char.isalpha(): + return char + # ctrl + lettre seul = raccourci (Ctrl+S, Ctrl+C) → pas un caractère + return None + return None + + def _merge_consecutive_text_inputs(steps: list) -> list: """Fusionne les text_input consécutifs en un seul.""" merged = [] @@ -577,12 +650,26 @@ def build_replay_from_raw_events( # Tous les text_input consécutifs sont fusionnés en un seul, indépendamment # du gap temporel. L'utilisateur tape lettre par lettre mais on veut un # seul "type" avec tout le texte dans le replay. + # Les key_combos qui produisent un caractère imprimable (ex: AltGr+0 → @) + # sont convertis en text_input pour être fusionnés avec le texte adjacent. # Seul un changement de fenêtre (window_title différent) coupe la fusion. merged_events = [] for evt in actionable_events: evt_type = evt.get("type", "") evt_ts = float(evt.get("timestamp", 0)) + # Convertir les key_combos qui produisent un caractère imprimable + # en text_input pour qu'ils soient fusionnés avec le texte adjacent. + # Ex: AltGr+0 capturé comme ['ctrl', '@'] → text_input '@' + if evt_type in ("key_combo", "key_press"): + keys = _sanitize_keys(evt.get("keys", [])) + printable = _key_combo_printable_char(keys) + if printable: + # Transformer en text_input pour fusion + evt = dict(evt, type="text_input", text=printable) + evt_type = "text_input" + # Pas de raw_keys pour ce caractère (sera collé via clipboard) + if evt_type == "text_input": text = evt.get("text", "") if not text: @@ -624,6 +711,34 @@ def build_replay_from_raw_events( else: merged_events.append(dict(evt)) + # ── 3b. Reconstruire le texte correct depuis les raw_keys ── + # Les raw_keys contiennent les vk codes exacts (layout-indépendant) + # qui permettent de corriger les erreurs de capture AZERTY + # (ex: numpad / capturé comme '!' → corrigé en '/') + # ATTENTION : ne reconstruire QUE si le texte reconstruit a la même + # longueur que le texte original. Si des caractères viennent de + # key_combos convertis (ex: @ de AltGr), ils n'ont pas de raw_keys + # et la reconstruction les perdrait. + for evt in merged_events: + if evt.get("type") == "text_input" and evt.get("raw_keys"): + reconstructed = _reconstruct_text_from_raw_keys(evt["raw_keys"]) + original = evt.get("text", "") + if reconstructed and len(reconstructed) == len(original): + # Même longueur → remplacement sûr (corrige les chars numpad) + evt["text"] = reconstructed + if reconstructed != original: + logger.debug( + "Texte reconstruit depuis raw_keys : '%s' → '%s'", + original[:50], reconstructed[:50], + ) + elif reconstructed and len(reconstructed) < len(original): + # Longueur différente → des chars viennent de key_combos convertis + # Garder le texte original (qui inclut les chars fusionnés) + logger.debug( + "Texte non reconstruit (longueur diff) : '%s' (%d) vs '%s' (%d)", + original[:50], len(original), reconstructed[:50], len(reconstructed), + ) + # ── 4. Convertir en actions replay normalisées ── actions = [] last_ts = 0.0 @@ -729,9 +844,10 @@ def build_replay_from_raw_events( "y_relative": y_relative, }, } - # Propager les infos textuelles pour compatibilité - if window_title: - action["target_spec"]["by_text"] = window_title + # NE PAS mettre window_title comme by_text ! + # by_text doit être le texte de l'ÉLÉMENT cliqué, pas le titre de la fenêtre. + # Sinon le template matching texte cherche "13071967.txt – Bloc-notes" + # sur l'écran et clique sur la barre de titre au lieu du bon élément. elif evt_type == "text_input": text = evt.get("text", "")