"""Envia una imagen (con caption opcional) a un chat de WhatsApp Web via Chrome DevTools Protocol. Compone `whatsapp_open_chat` (abrir + verificar destinatario) con primitivas CDP del registry (`cdp_eval`, `cdp_click_xy`, `cdp_set_file_input`, `cdp_type_chars`) para adjuntar y enviar una imagen a un contacto/grupo SIN abrir ventana nueva ni darle foco al sistema. Flujo (modelo de bandeja de medios INLINE de la WhatsApp Web actual), con salvaguarda anti-envio-al-contacto-equivocado: 1. Abre el chat por su nombre exacto (`open_first=True`). Si no abre, aborta. Con `open_first=False`, asume el chat abierto pero VERIFICA que el aria-label del composer contiene el nombre; si no, aborta por seguridad. 2. Hace click de raton real en el boton "Adjuntar" del footer: esto expone el `` "vivo" que escucha la SPA (antes de pulsarlo el input persistente no dispara el preview). 3. Asigna la imagen al input via `cdp_set_file_input` (`DOM.setFileInputFiles`): la imagen aparece como miniatura en la bandeja inline sobre el composer. 4. Espera a que el preview aparezca (boton "Quitar archivo adjunto" presente). 5. Si `caption` no esta vacio, enfoca el composer del footer y teclea el texto con teclado CDP real (`cdp_type_chars`); verifica el `innerText` antes de enviar. 6. Click de raton real en el boton enviar (icono `wds-ic-send-filled`) y verifica que la bandeja se cerro (sin adjuntos) para confirmar el envio. Validado contra WhatsApp Web real. Accion CON EFECTO REAL E IRREVERSIBLE: envia la imagen de verdad. """ import json import os import sys import time sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from browser.cdp_eval import cdp_eval from browser.cdp_click_xy import cdp_click_xy from browser.cdp_type_chars import cdp_type_chars from browser.cdp_set_file_input import cdp_set_file_input from browser.whatsapp_open_chat import whatsapp_open_chat def _center(expr: str, port: int, substr: str): """Evalua una expresion que devuelve JSON {x,y} (o null) y la parsea a dict/None.""" r = cdp_eval(expr, port=port, target_url_substr=substr) val = r.get("value") if not val: return None try: return json.loads(val) except Exception: # noqa: BLE001 — value no-JSON return None def _attachment_count(port: int, substr: str) -> int: """Numero de adjuntos en la bandeja inline (botones 'Quitar archivo adjunto').""" r = cdp_eval( '/*PREVIEW*/document.querySelectorAll(\'[aria-label="Quitar archivo adjunto"]\').length', port=port, target_url_substr=substr, ) v = r.get("value") return v if isinstance(v, int) else 0 def whatsapp_send_image( name: str, image_path: str, *, caption: str = "", port: int = 9222, target_url_substr: str = "whatsapp", open_first: bool = True, ) -> dict: """Envia una imagen (con caption opcional) a un chat de WhatsApp Web ya logueado. Accion CON EFECTO: envia la imagen DE VERDAD (no reversible). Verifica `name`. Args: name: Nombre EXACTO del chat/grupo destinatario, tal y como aparece en la lista lateral. Se usa para abrir el chat y como salvaguarda de que el composer apunta al destinatario correcto antes de adjuntar. image_path: Ruta de la imagen a enviar. Se expande (`~`) y se convierte a ruta ABSOLUTA; debe existir en disco. caption: Texto opcional que acompana la imagen. Se teclea en el composer con teclado CDP real; "" (default) envia la imagen sin caption. port: Puerto de remote debugging de Chrome. Default 9222. target_url_substr: Substring que debe contener la URL del target (pestana). Default "whatsapp". open_first: Si True (default), abre el chat por su nombre antes de adjuntar. Si False, asume el chat ya abierto pero verifica el aria-label del composer contra `name` (aborta si no coincide). Returns: dict con claves: ok: bool — alias de sent (True si la imagen se envio). sent: bool — True si la imagen se adjunto, (caption escrito) y se envio. recipient: str — el nombre solicitado. image: str — ruta absoluta de la imagen. caption: str — caption solicitado. error: str — motivo del fallo (vacio si sent=True). Nunca lanza: los fallos se reportan en "sent"/"ok" + "error". """ S = target_url_substr abs_img = os.path.abspath(os.path.expanduser(image_path)) def fail(error: str) -> dict: return {"ok": False, "sent": False, "recipient": name, "image": abs_img, "caption": caption, "error": error} # 0. La imagen debe existir. if not os.path.isfile(abs_img): return fail(f"imagen no encontrada: {abs_img}") # 1. Abrir + verificar destinatario correcto (salvaguarda anti-equivocacion). if open_first: o = whatsapp_open_chat(name, port=port, target_url_substr=S) if not o.get("opened"): return fail(o.get("reason", "no se pudo abrir el chat")) else: chk = cdp_eval( '/*LABEL*/var b=document.querySelector(\'footer div[contenteditable="true"]\'); ' "b?b.getAttribute('aria-label'):null", port=port, target_url_substr=S, ) if name not in (chk.get("value") or ""): return fail("el chat abierto no coincide con el destinatario; abortado por seguridad") # 2. Click real en "Adjuntar" para exponer el vivo. adj = _center( '/*ADJUNTAR*/(() => {const e=document.querySelector(\'button[aria-label="Adjuntar"]\');' "if(!e)return null;const b=e.getBoundingClientRect();" "return JSON.stringify({x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)});})()", port, S, ) if not adj: return fail("boton 'Adjuntar' no encontrado en el footer") cdp_click_xy(adj["x"], adj["y"], port=port, target_url_substr=S) time.sleep(0.8) # 3. Asignar la imagen al input multiple (el que se activa tras Adjuntar); # fallback al primer input file si el selector con [multiple] no resuelve. r = cdp_set_file_input('input[type="file"][multiple]', abs_img, port=port, target_url_substr=S) if not r.get("ok"): r = cdp_set_file_input('input[type="file"]', abs_img, port=port, target_url_substr=S) if not r.get("ok"): return fail("no se pudo adjuntar la imagen: " + r.get("error", "")) # 4. Esperar a que el preview/bandeja aparezca (adjunto presente). attached = False for _ in range(15): time.sleep(0.2) if _attachment_count(port, S) > 0: attached = True break if not attached: return fail("el preview no aparecio tras adjuntar la imagen") # 5. Caption opcional: enfocar el composer y teclear con teclado real. if caption: cdp_eval( '/*FOCUS*/var b=document.querySelector(\'div[contenteditable="true"]\'); ' "if(b){b.focus();}", port=port, target_url_substr=S, ) time.sleep(0.25) cdp_type_chars(caption, port=port, target_url_substr=S, delay_ms=15) time.sleep(0.3) chk = cdp_eval( '/*COMPOSER*/var b=document.querySelector(\'div[contenteditable="true"]\'); ' "b?b.innerText.replace(/\\n/g,''):''", port=port, target_url_substr=S, ) composer = chk.get("value") or "" if composer != caption: return fail("el composer no contiene el caption esperado (no enviado): " + repr(composer)) # 6. Click real en el boton enviar (icono wds-ic-send-filled). snd = _center( '/*SEND*/(() => {const e=document.querySelector(\'span[data-icon="wds-ic-send-filled"]\');' "if(!e)return null;const b=e.getBoundingClientRect();" "if(b.width===0)return null;" "return JSON.stringify({x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)});})()", port, S, ) if not snd: return fail("boton de enviar (wds-ic-send-filled) no encontrado") cdp_click_xy(snd["x"], snd["y"], port=port, target_url_substr=S) # 7. Verificar que la bandeja se cerro (sin adjuntos) -> envio confirmado. for _ in range(15): time.sleep(0.2) if _attachment_count(port, S) == 0: return {"ok": True, "sent": True, "recipient": name, "image": abs_img, "caption": caption, "error": ""} return fail("la bandeja no se cerro tras pulsar enviar; envio incierto") if __name__ == "__main__": chat = sys.argv[1] if len(sys.argv) > 1 else "NOTAS WASAP" img = sys.argv[2] if len(sys.argv) > 2 else "" cap = sys.argv[3] if len(sys.argv) > 3 else "" out = whatsapp_send_image(chat, img, caption=cap, port=9222, target_url_substr="whatsapp") print(json.dumps(out, ensure_ascii=False, indent=2))