""" Utilitaires d'entrée pour exécuter des actions UI (souris, clavier, etc.). Support du clavier AZERTY et gestion du rollback d'actions. """ import time import pyautogui from typing import Dict, Any, Optional, Tuple from enum import Enum from ..logger import Logger class ActionType(Enum): """Types d'actions UI supportées.""" CLICK = "click" TYPE = "type" SCROLL = "scroll" WAIT = "wait" MOVE = "move" DRAG = "drag" class InputUtils: """ Gestionnaire d'entrées utilisateur pour exécuter des actions UI. Support du clavier AZERTY et rollback d'actions. """ def __init__(self, logger: Logger, config: Dict[str, Any]): """ Initialise les utilitaires d'entrée. Args: logger: Logger pour journalisation config: Configuration globale """ self.logger = logger self.config = config # Configuration PyAutoGUI pyautogui.FAILSAFE = True # Déplacer souris dans coin = arrêt pyautogui.PAUSE = config.get("input", {}).get("pause_between_actions", 0.1) # Historique des actions pour rollback self.action_history = [] # Mapping AZERTY pour caractères spéciaux self.azerty_mapping = { '0': 'à', '1': '&', '2': 'é', '3': '"', '4': "'", '5': '(', '6': '-', '7': 'è', '8': '_', '9': 'ç', '.': ':', '/': '!', ',': ';', ';': ',', ':': '.', '!': '/', '?': 'M', # Shift + , } self.logger.log_action({ "action": "input_utils_initialized", "failsafe": True, "pause": pyautogui.PAUSE }) def click( self, x: int, y: int, button: str = "left", clicks: int = 1, interval: float = 0.0 ) -> bool: """ Effectue un clic souris à la position spécifiée. Args: x: Coordonnée X y: Coordonnée Y button: Bouton souris ("left", "right", "middle") clicks: Nombre de clics interval: Intervalle entre clics multiples Returns: True si succès, False sinon """ try: # Enregistrer position actuelle pour rollback current_pos = pyautogui.position() # Effectuer le clic pyautogui.click(x, y, clicks=clicks, interval=interval, button=button) # Enregistrer dans l'historique action_record = { "type": ActionType.CLICK.value, "x": x, "y": y, "button": button, "clicks": clicks, "previous_position": current_pos, "timestamp": time.time() } self.action_history.append(action_record) self.logger.log_action({ "action": "click_executed", **action_record }) return True except Exception as e: self.logger.log_action({ "action": "click_failed", "x": x, "y": y, "error": str(e) }) return False def type_text( self, text: str, interval: float = 0.0, use_azerty: bool = True ) -> bool: """ Saisit du texte au clavier. Args: text: Texte à saisir interval: Intervalle entre chaque caractère use_azerty: Utiliser le mapping AZERTY Returns: True si succès, False sinon """ try: # Convertir pour AZERTY si nécessaire if use_azerty: converted_text = self._convert_to_azerty(text) else: converted_text = text # Saisir le texte pyautogui.write(converted_text, interval=interval) # Enregistrer dans l'historique action_record = { "type": ActionType.TYPE.value, "text": text, "converted_text": converted_text, "length": len(text), "timestamp": time.time() } self.action_history.append(action_record) self.logger.log_action({ "action": "text_typed", **action_record }) return True except Exception as e: self.logger.log_action({ "action": "type_text_failed", "text": text[:50], # Limiter pour logs "error": str(e) }) return False def scroll( self, direction: str, amount: int = 3, x: Optional[int] = None, y: Optional[int] = None ) -> bool: """ Effectue un défilement. Args: direction: Direction ("up", "down", "left", "right") amount: Quantité de défilement (nombre de "clics" de molette) x: Position X optionnelle y: Position Y optionnelle Returns: True si succès, False sinon """ try: # Calculer le montant de défilement if direction in ["up", "right"]: scroll_amount = amount elif direction in ["down", "left"]: scroll_amount = -amount else: raise ValueError(f"Direction invalide: {direction}") # Déplacer la souris si position spécifiée if x is not None and y is not None: pyautogui.moveTo(x, y) # Effectuer le défilement if direction in ["up", "down"]: pyautogui.scroll(scroll_amount) else: pyautogui.hscroll(scroll_amount) # Enregistrer dans l'historique action_record = { "type": ActionType.SCROLL.value, "direction": direction, "amount": amount, "x": x, "y": y, "timestamp": time.time() } self.action_history.append(action_record) self.logger.log_action({ "action": "scroll_executed", **action_record }) return True except Exception as e: self.logger.log_action({ "action": "scroll_failed", "direction": direction, "amount": amount, "error": str(e) }) return False def wait(self, duration: float) -> bool: """ Attend pendant une durée spécifiée. Args: duration: Durée en secondes Returns: True """ try: time.sleep(duration) action_record = { "type": ActionType.WAIT.value, "duration": duration, "timestamp": time.time() } self.action_history.append(action_record) self.logger.log_action({ "action": "wait_executed", **action_record }) return True except Exception as e: self.logger.log_action({ "action": "wait_failed", "duration": duration, "error": str(e) }) return False def move(self, x: int, y: int, duration: float = 0.2) -> bool: """ Déplace la souris vers une position. Args: x: Coordonnée X y: Coordonnée Y duration: Durée du mouvement en secondes Returns: True si succès, False sinon """ try: current_pos = pyautogui.position() pyautogui.moveTo(x, y, duration=duration) action_record = { "type": ActionType.MOVE.value, "x": x, "y": y, "previous_position": current_pos, "duration": duration, "timestamp": time.time() } self.action_history.append(action_record) self.logger.log_action({ "action": "move_executed", **action_record }) return True except Exception as e: self.logger.log_action({ "action": "move_failed", "x": x, "y": y, "error": str(e) }) return False def drag( self, start_x: int, start_y: int, end_x: int, end_y: int, duration: float = 0.5, button: str = "left" ) -> bool: """ Effectue un glisser-déposer. Args: start_x: X de départ start_y: Y de départ end_x: X d'arrivée end_y: Y d'arrivée duration: Durée du glissement button: Bouton souris Returns: True si succès, False sinon """ try: current_pos = pyautogui.position() pyautogui.moveTo(start_x, start_y) pyautogui.drag(end_x - start_x, end_y - start_y, duration=duration, button=button) action_record = { "type": ActionType.DRAG.value, "start_x": start_x, "start_y": start_y, "end_x": end_x, "end_y": end_y, "previous_position": current_pos, "duration": duration, "button": button, "timestamp": time.time() } self.action_history.append(action_record) self.logger.log_action({ "action": "drag_executed", **action_record }) return True except Exception as e: self.logger.log_action({ "action": "drag_failed", "start": (start_x, start_y), "end": (end_x, end_y), "error": str(e) }) return False def execute_inverse_action(self, action: Dict[str, Any]) -> bool: """ Exécute l'action inverse pour rollback. Args: action: Action à inverser Returns: True si succès, False sinon """ inverse = self.get_inverse_action(action) if not inverse: return False action_type = inverse.get("type") if action_type == ActionType.MOVE.value: return self.move(inverse["x"], inverse["y"], inverse.get("duration", 0.2)) elif action_type == ActionType.SCROLL.value: return self.scroll( inverse["direction"], inverse["amount"], inverse.get("x"), inverse.get("y") ) elif action_type == ActionType.DRAG.value: return self.drag( inverse["start_x"], inverse["start_y"], inverse["end_x"], inverse["end_y"], inverse.get("duration", 0.5), inverse.get("button", "left") ) elif action_type == "press_key": # Exécuter les suppressions for _ in range(inverse.get("presses", 0)): pyautogui.press("backspace") return True return False def get_inverse_action(self, action: Dict[str, Any]) -> Optional[Dict[str, Any]]: """ Génère l'action inverse pour rollback. Args: action: Action à inverser Returns: Action inverse ou None si non inversible """ action_type = action.get("type") if action_type == ActionType.CLICK.value: # Un clic n'est pas vraiment inversible # On peut retourner à la position précédente prev_pos = action.get("previous_position") if prev_pos: return { "type": ActionType.MOVE.value, "x": prev_pos[0], "y": prev_pos[1], "duration": 0.2 } elif action_type == ActionType.TYPE.value: # Inverser la saisie = supprimer le texte text_length = action.get("length", 0) return { "type": "press_key", "key": "backspace", "presses": text_length } elif action_type == ActionType.SCROLL.value: # Inverser le défilement direction = action.get("direction") amount = action.get("amount") inverse_direction = { "up": "down", "down": "up", "left": "right", "right": "left" }.get(direction) return { "type": ActionType.SCROLL.value, "direction": inverse_direction, "amount": amount, "x": action.get("x"), "y": action.get("y") } elif action_type == ActionType.MOVE.value: # Retourner à la position précédente prev_pos = action.get("previous_position") if prev_pos: return { "type": ActionType.MOVE.value, "x": prev_pos[0], "y": prev_pos[1], "duration": 0.2 } elif action_type == ActionType.DRAG.value: # Inverser le glissement return { "type": ActionType.DRAG.value, "start_x": action.get("end_x"), "start_y": action.get("end_y"), "end_x": action.get("start_x"), "end_y": action.get("start_y"), "duration": action.get("duration", 0.5), "button": action.get("button", "left") } elif action_type == ActionType.WAIT.value: # L'attente n'a pas d'inverse return None return None def _convert_to_azerty(self, text: str) -> str: """ Convertit du texte pour clavier AZERTY. Args: text: Texte à convertir Returns: Texte converti """ # Pour l'instant, retourner tel quel # PyAutoGUI gère déjà le layout clavier du système # Cette méthode peut être étendue si nécessaire return text def get_action_history(self, limit: int = 50) -> list: """ Retourne l'historique des actions. Args: limit: Nombre maximum d'actions à retourner Returns: Liste des dernières actions """ return self.action_history[-limit:] def clear_history(self): """Efface l'historique des actions.""" self.action_history = [] self.logger.log_action({ "action": "action_history_cleared" }) def execute_action(self, action_data: Dict[str, Any]) -> bool: """ Exécute une action depuis un dictionnaire de données. Args: action_data: Données de l'action à exécuter { "action_type": str, "bbox": (x, y, w, h), "parameters": dict } Returns: True si succès, False sinon """ action_type = action_data.get("action_type", "").lower() bbox = action_data.get("bbox", (0, 0, 0, 0)) params = action_data.get("parameters", {}) # Calculer le centre de la bbox pour les actions de clic x, y, w, h = bbox center_x = x + w // 2 center_y = y + h // 2 if action_type == "click": button = params.get("button", "left") clicks = params.get("clicks", 1) return self.click(center_x, center_y, button=button, clicks=clicks) elif action_type == "double_click": return self.click(center_x, center_y, clicks=2) elif action_type == "right_click": return self.click(center_x, center_y, button="right") elif action_type == "type": text = params.get("text", "") interval = params.get("interval", 0.0) return self.type_text(text, interval=interval) elif action_type == "scroll": direction = params.get("direction", "down") amount = params.get("amount", 3) return self.scroll(direction, amount, center_x, center_y) elif action_type == "wait": duration = params.get("duration", 1.0) return self.wait(duration) elif action_type == "move": duration = params.get("duration", 0.2) return self.move(center_x, center_y, duration=duration) elif action_type == "drag": end_bbox = params.get("end_bbox", bbox) end_x, end_y, end_w, end_h = end_bbox end_center_x = end_x + end_w // 2 end_center_y = end_y + end_h // 2 duration = params.get("duration", 0.5) button = params.get("button", "left") return self.drag(center_x, center_y, end_center_x, end_center_y, duration, button) else: self.logger.log_action({ "action": "unknown_action_type", "action_type": action_type }) return False