import re import time import pyperclip from selenium.common.exceptions import StaleElementReferenceException, TimeoutException, WebDriverException from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait def es_numero_telefono(destino): return bool(re.fullmatch(r"\+?\d{7,15}", destino.strip())) def elementos_visibles(driver, by, selector): try: elementos = driver.find_elements(by, selector) except Exception: return [] visibles = [] for elemento in elementos: try: if elemento.is_displayed(): visibles.append(elemento) except (StaleElementReferenceException, WebDriverException): continue return visibles def primer_visible(driver, by, selector): elementos = elementos_visibles(driver, by, selector) return elementos[0] if elementos else None def esperar_primer_visible(driver, candidatos, timeout=15): fin = time.time() + timeout while time.time() < fin: for by, selector in candidatos: elemento = primer_visible(driver, by, selector) if elemento: return elemento time.sleep(0.3) return None def click_seguro(driver, elemento): try: driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", elemento) time.sleep(0.1) elemento.click() except Exception: try: driver.execute_script("arguments[0].click();", elemento) except Exception: pass def limpiar_campo(driver, elemento): click_seguro(driver, elemento) ActionChains(driver).key_down(Keys.CONTROL).send_keys("a").key_up(Keys.CONTROL).send_keys(Keys.DELETE).perform() time.sleep(0.2) def buscar_caja_mensaje(driver, timeout=20): candidatos = [ (By.CSS_SELECTOR, "[data-testid='conversation-compose-box-input']"), (By.CSS_SELECTOR, "footer div[contenteditable='true'][role='textbox']"), (By.CSS_SELECTOR, "footer div[contenteditable='true'][data-lexical-editor='true']"), (By.CSS_SELECTOR, "footer div[contenteditable='true']"), (By.CSS_SELECTOR, "div[contenteditable='true'][data-tab='10']"), (By.CSS_SELECTOR, "div[contenteditable='true'][data-tab='9']"), (By.CSS_SELECTOR, "footer div[aria-placeholder*='mensaje' i]"), (By.CSS_SELECTOR, "footer div[aria-placeholder*='message' i]"), (By.CSS_SELECTOR, "footer div[aria-label*='mensaje' i]"), (By.CSS_SELECTOR, "footer div[aria-label*='message' i]"), (By.XPATH, "//footer//*[@contenteditable='true']"), ] return esperar_primer_visible(driver, candidatos, timeout=timeout) def abrir_buscador(driver, timeout=15): try: WebDriverWait(driver, 15).until( EC.presence_of_element_located( ( By.CSS_SELECTOR, "#side, #pane-side, [data-testid='chat-list'], div[role='grid']", ) ) ) except Exception: pass candidatos = [ (By.CSS_SELECTOR, "input[role='textbox'][data-tab='3']"), (By.CSS_SELECTOR, "input[aria-label*='Buscar' i]"), (By.CSS_SELECTOR, "input[placeholder*='Buscar' i]"), (By.CSS_SELECTOR, "input[aria-label*='Search' i]"), (By.CSS_SELECTOR, "input[placeholder*='Search' i]"), (By.CSS_SELECTOR, "[data-testid='chat-list-search']"), (By.CSS_SELECTOR, "div[contenteditable='true'][data-tab='3']"), (By.CSS_SELECTOR, "div[contenteditable='true'][role='textbox'][aria-label*='buscar' i]"), (By.CSS_SELECTOR, "div[contenteditable='true'][role='textbox'][aria-label*='search' i]"), (By.CSS_SELECTOR, "div[contenteditable='true'][role='textbox'][aria-placeholder*='buscar' i]"), (By.CSS_SELECTOR, "div[contenteditable='true'][role='textbox'][aria-placeholder*='search' i]"), ( By.XPATH, "//*[@contenteditable='true' and ancestor::*[@id='side' or @id='pane-side']]", ), ( By.XPATH, "//*[@contenteditable='true' and contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'buscar')]", ), ( By.XPATH, "//*[@contenteditable='true' and contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'search')]", ), ] fin = time.time() + timeout while time.time() < fin: for by, selector in candidatos: for elemento in elementos_visibles(driver, by, selector): try: rect = elemento.rect if rect and rect.get("y", 9999) > 350: continue click_seguro(driver, elemento) if (elemento.tag_name or "").lower() == "input": return elemento return elemento except (StaleElementReferenceException, WebDriverException): continue time.sleep(0.5) botones_busqueda = [ (By.CSS_SELECTOR, "button[aria-label*='buscar' i]"), (By.CSS_SELECTOR, "button[aria-label*='search' i]"), (By.CSS_SELECTOR, "div[role='button'][aria-label*='buscar' i]"), (By.CSS_SELECTOR, "div[role='button'][aria-label*='search' i]"), (By.CSS_SELECTOR, "span[data-icon='search']"), ] boton = esperar_primer_visible(driver, botones_busqueda, timeout=3) if boton: click_seguro(driver, boton) return esperar_primer_visible(driver, candidatos, timeout=5) return None def obtener_nombre_resultado(chat): selectores_nombre = [ "span[title]", "span[dir='auto']", "div[role='gridcell'] span[dir='auto']", ] for selector in selectores_nombre: try: for span in chat.find_elements(By.CSS_SELECTOR, selector): texto = (span.get_attribute("title") or span.text or "").strip() if texto: return texto except (StaleElementReferenceException, WebDriverException): continue return "" def buscar_resultados_chat(driver): candidatos = [ "[data-testid='cell-frame-container']", "#pane-side div[tabindex='-1']", "#side div[tabindex='-1']", "div[role='grid'] div[role='row']", "div[aria-label][role='grid'] div[role='row']", ] resultados = [] vistos = set() for selector in candidatos: for elemento in elementos_visibles(driver, By.CSS_SELECTOR, selector): try: key = elemento.id except Exception: key = id(elemento) if key not in vistos: resultados.append(elemento) vistos.add(key) return resultados def abrir_chat(driver, tipo, destino): try: if es_numero_telefono(destino): numero = destino.replace("+", "").strip() print(f"Abriendo chat directo con numero {numero}...") driver.get(f"https://web.whatsapp.com/send?phone={numero}") if buscar_caja_mensaje(driver, timeout=35): print("Chat directo abierto.") return True print("No se pudo confirmar la apertura del chat directo.") return False print(f"Buscando {tipo} '{destino}'...") buscador = abrir_buscador(driver) if not buscador: print("No se encontro buscador.") return False limpiar_campo(driver, buscador) time.sleep(0.5) for caracter in destino: buscador.send_keys(caracter) time.sleep(0.05) try: WebDriverWait(driver, 15).until(lambda d: len(buscar_resultados_chat(d)) > 0) except TimeoutException: print("No aparecieron resultados de busqueda.") time.sleep(1) destino_norm = destino.strip().lower() chat_exacto = None chat_parcial = None for chat in buscar_resultados_chat(driver): try: nombre_chat = obtener_nombre_resultado(chat) if not nombre_chat: continue nombre_norm = nombre_chat.strip().lower() print(f"Resultado encontrado: {nombre_chat}") if nombre_norm == destino_norm: chat_exacto = chat break if destino_norm in nombre_norm or nombre_norm in destino_norm: chat_parcial = chat except (StaleElementReferenceException, WebDriverException): continue chat_objetivo = chat_exacto or chat_parcial if chat_objetivo: click_seguro(driver, chat_objetivo) if buscar_caja_mensaje(driver, timeout=15): print(f"{tipo.capitalize()} abierto.") return True print("Se selecciono el chat pero no se detecto caja mensaje.") return False try: buscador.send_keys(Keys.ENTER) if buscar_caja_mensaje(driver, timeout=8): print(f"{tipo.capitalize()} '{destino}' abierto usando el primer resultado.") return True except Exception: pass print(f"No se encontro {tipo} '{destino}'.") return False except TimeoutException: print(f"Timeout abriendo chat '{destino}'.") return False except WebDriverException as exc: print(f"Error navegador: {exc}") return False except Exception as exc: print(f"Error inesperado: {exc}") return False def enviar_mensaje(driver, mensaje): try: caja = buscar_caja_mensaje(driver, timeout=20) if not caja: raise Exception("No se encontro caja texto") pyperclip.copy(mensaje.replace("\\n", "\n")) limpiar_campo(driver, caja) time.sleep(0.2) caja.send_keys(Keys.CONTROL, "v") time.sleep(0.3) botones_envio = [ (By.CSS_SELECTOR, "button[aria-label*='enviar' i]"), (By.CSS_SELECTOR, "button[aria-label*='send' i]"), (By.CSS_SELECTOR, "[data-icon*='send']"), (By.CSS_SELECTOR, "span[data-testid*='send']"), ] boton = esperar_primer_visible(driver, botones_envio, timeout=2) if boton: click_seguro(driver, boton) else: caja.send_keys(Keys.ENTER) print("Mensaje enviado.") return True except Exception as exc: print(f"No se pudo enviar: {exc}") return False def obtener_nombre_chat_actual(driver): try: nombre_elem = driver.find_element(By.XPATH, "//header//span[@dir='auto' and @title]") return nombre_elem.get_attribute("title").strip() except Exception: pass try: encabezado = driver.find_element(By.CSS_SELECTOR, "header") for selector in ["span[title]", "span[dir='auto']", "div[role='button'] span"]: for elemento in encabezado.find_elements(By.CSS_SELECTOR, selector): texto = (elemento.get_attribute("title") or elemento.text or "").strip() if texto: return texto except Exception: pass return "chat_desconocido" def verificar_ticks(driver): try: metas = driver.find_elements(By.CSS_SELECTOR, "[data-testid='msg-meta']") ultimo_con_status = None for meta in reversed(metas): if meta.find_elements(By.CSS_SELECTOR, "svg title, span[aria-label], [data-icon]"): ultimo_con_status = meta break if not ultimo_con_status: containers = driver.find_elements(By.CSS_SELECTOR, "[data-testid='msg-container']") if containers and containers[-1].find_elements(By.CSS_SELECTOR, "[data-icon='message-fail']"): return "FALLIDO" return "ENVIADO (SIN CONFIRMACION)" titles = ultimo_con_status.find_elements(By.CSS_SELECTOR, "svg title") if titles: titulo = (titles[-1].get_attribute("textContent") or titles[-1].text).strip() mapping = { "wds-ic-read": "LEIDO (2 ticks azules)", "wds-ic-status-pending": "PENDIENTE (reloj)", "wds-ic-status-sent": "ENVIADO (1 tick)", "wds-ic-sent": "ENVIADO (1 tick)", "wds-ic-status-delivered": "ENTREGADO (2 ticks)", "wds-ic-delivered": "ENTREGADO (2 ticks)", } return mapping.get(titulo, f"DESCONOCIDO ({titulo})") aria_spans = ultimo_con_status.find_elements(By.CSS_SELECTOR, "span[aria-label]") if aria_spans: label = aria_spans[-1].get_attribute("aria-label").strip() aria_map = { "Pendiente": "PENDIENTE (reloj)", "Enviado": "ENVIADO (1 tick)", "Entregado": "ENTREGADO (2 ticks)", "LeĆ­do": "LEIDO (2 ticks azules)", "Read": "LEIDO (2 ticks azules)", } return aria_map.get(label, f"DESCONOCIDO ({label})") status_icons = ultimo_con_status.find_elements(By.CSS_SELECTOR, "[data-icon]") if status_icons: icono = status_icons[-1].get_attribute("data-icon") legacy = { "msg-time": "PENDIENTE (reloj)", "msg-check": "ENVIADO (1 tick)", "msg-dblcheck": "ENTREGADO (2 ticks)", "msg-dblcheck-ack": "LEIDO (2 ticks azules)", } return legacy.get(icono, f"DESCONOCIDO ({icono})") return "ENVIADO (SIN DETALLE)" except Exception: return "ERROR_VERIFICACION_STATUS" def get_last_outgoing_text(driver): try: elems = driver.find_elements( By.CSS_SELECTOR, "[data-testid='msg-container'] span.selectable-text, " "[data-testid='msg-container'] [data-testid='selectable-text']", ) if elems: texto = elems[-1].text.strip() if texto: return texto elems = driver.find_elements(By.CSS_SELECTOR, "span.selectable-text") for elem in reversed(elems[-10:]): texto = elem.text.strip() if texto: return texto except Exception: pass return "" def validate_sent(driver, mensaje, timeout=8): short = (mensaje.strip().replace("\n", " "))[:40].strip().lower() if not short: return False inicio = time.time() while time.time() - inicio < timeout: try: if driver.find_elements( By.CSS_SELECTOR, "canvas[aria-label*='Scan'], canvas[aria-label*='escan' i], div[data-ref] canvas", ): print("Se detecto QR durante la validacion: sesion perdida.") return None except Exception: pass last = get_last_outgoing_text(driver) if last: last_norm = last.replace("\n", " ").strip().lower() if short in last_norm or last_norm in short: return True if last_norm.startswith(short[:20]): return True time.sleep(0.8) return False