feat: popup VLM double-appel, auth Bearer partout, texte AZERTY corrigé

- Popup handling via double appel VLM (détection + localisation précise du bouton)
- Reconstruction texte depuis raw_keys (numpad /, @ AltGr fusionné)
- Clipboard paste pour texte riche, raw_keys pour commandes simples (Win+R)
- Skip des release orphelins dans raw_keys (fix menu Démarrer parasite)
- Auth Bearer sur toutes les requêtes agent → streaming server
- Endpoints /replay/next et /stream/image publics (agent Rust legacy)
- alt_gr ajouté dans _MODIFIER_ONLY_KEYS
- _key_combo_printable_char détecte ctrl+@ comme caractère imprimable
- start.bat tue les anciens process (python + rpa-agent) au démarrage
- Heartbeat avec token Bearer dans main.py et deploy/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-30 16:45:09 +02:00
parent c2dc8f8fe4
commit 647aa610fd
10 changed files with 307 additions and 56 deletions

View File

@@ -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", "")