From 4c4eec4b1d0c1ad34aae40dbfd7757f93566046a Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 26 Jun 2026 23:09:32 +0200 Subject: [PATCH] feat(browser): auto-commit con 6 cambios Co-Authored-By: Claude Opus 4.7 (1M context) --- .../functions/browser/cdp_set_file_input.md | 82 +++++++ .../functions/browser/cdp_set_file_input.py | 163 ++++++++++++++ .../browser/cdp_set_file_input_test.py | 144 ++++++++++++ .../functions/browser/whatsapp_send_image.md | 94 ++++++++ .../functions/browser/whatsapp_send_image.py | 208 ++++++++++++++++++ .../browser/whatsapp_send_image_test.py | 171 ++++++++++++++ 6 files changed, 862 insertions(+) create mode 100644 python/functions/browser/cdp_set_file_input.md create mode 100644 python/functions/browser/cdp_set_file_input.py create mode 100644 python/functions/browser/cdp_set_file_input_test.py create mode 100644 python/functions/browser/whatsapp_send_image.md create mode 100644 python/functions/browser/whatsapp_send_image.py create mode 100644 python/functions/browser/whatsapp_send_image_test.py diff --git a/python/functions/browser/cdp_set_file_input.md b/python/functions/browser/cdp_set_file_input.md new file mode 100644 index 00000000..82876f6f --- /dev/null +++ b/python/functions/browser/cdp_set_file_input.md @@ -0,0 +1,82 @@ +--- +name: cdp_set_file_input +kind: function +lang: py +domain: browser +version: "1.0.0" +purity: impure +signature: "def cdp_set_file_input(selector: str, file_paths, *, port: int = 9222, target_url_substr: str = '', timeout_s: float = 10.0) -> dict" +description: "Asigna uno o varios archivos a un de una pestana de un Chrome con remote debugging, via CDP, SIN abrir el dialogo nativo del sistema operativo. Localiza el target por substring de URL, abre el WebSocket y ejecuta DOM.enable -> DOM.getDocument -> DOM.querySelector(selector) -> DOM.setFileInputFiles con las rutas ABSOLUTAS. Es el unico metodo robusto para subir archivos por CDP: el navegador no permite escribir el value de un file input desde JS (seguridad) y simular drag&drop es fragil; setFileInputFiles inyecta los File y dispara el evento change que la SPA escucha. Base de whatsapp_send_image y de cualquier flujo de subida de archivos sobre el navegador diario sin robar el foco al usuario." +tags: [cdp, browser, automation, upload, file-input, python, navegator] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["json", "os", "urllib.request", "websocket"] +params_schema: + params: + - name: selector + desc: "Selector CSS del destino. Debe resolver a UN elemento (se usa el primer match). El input puede estar oculto (display:none); CDP lo localiza igual." + - name: file_paths + desc: "Ruta (str) o lista de rutas a asignar. Se expanden (~) y se convierten a rutas ABSOLUTAS; cada una debe existir en disco o se aborta con ok=False antes de tocar la red." + - name: port + desc: "Puerto de remote debugging de Chrome. Default 9222." + - name: target_url_substr + desc: "Substring que debe contener la URL del target (pestana). Si vacio, usa el primer target de tipo 'page'." + - name: timeout_s + desc: "Timeout en segundos para la conexion WebSocket. Default 10.0." + output: "dict {ok: bool, error: str, node_id: int (nodeId CDP del input localizado, 0 si no se encontro), selector: str (eco), files: list[str] (rutas absolutas asignadas)}. ok=True solo si el input se localizo y setFileInputFiles no devolvio error. Nunca lanza: errores de archivo/red/conexion/transport se devuelven en 'error' con ok=False." +tested: true +tests: ["test_golden_asigna_archivos_al_input", "test_edge_archivo_inexistente_ok_false_sin_red", "test_edge_selector_no_encontrado_ok_false", "test_error_create_connection_lanza_ok_false"] +test_file_path: "python/functions/browser/cdp_set_file_input_test.py" +file_path: "python/functions/browser/cdp_set_file_input.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from browser.cdp_set_file_input import cdp_set_file_input + +# Requiere un Chrome lanzado con --remote-debugging-port=9222. +# Adjuntar una imagen al input de subida de una pestana (WhatsApp Web, un formulario, etc). +# El input "vivo" suele exponerse tras pulsar el boton "Adjuntar"/"Subir" (haz el click antes). +res = cdp_set_file_input( + 'input[type="file"][multiple]', + "/home/enmanuel/ComfyUI/output/item_icon_potion_00001_.png", + target_url_substr="whatsapp", +) +print(res["ok"], res["node_id"], res["error"]) +# -> True 120 +``` + +O directo por CLI: `python3 python/functions/browser/cdp_set_file_input.py 'input[type="file"]' /ruta/abs.png whatsapp`. + +## Cuando usarla + +Cuando necesites **subir/adjuntar un archivo** a una pestana abierta sin que aparezca el +dialogo nativo de archivos del sistema operativo (que CDP no puede operar). Es la primitiva +de subida sobre la que se construye `whatsapp_send_image_py_browser` y cualquier +automatizacion de formularios con ``. El patron tipico: primero un click +real (`cdp_click_xy_py_browser`) en el boton que expone el input vivo, luego esta funcion +con el selector del input y la ruta absoluta del archivo. + +## Gotchas + +- **El input debe existir en el DOM al llamar.** Muchas SPAs (WhatsApp Web) solo crean/activan + el `` "vivo" DESPUES de pulsar el boton de adjuntar. Haz el click real + primero; si asignas sobre un input persistente/decoy, `setFileInputFiles` puede devolver ok + pero la SPA no reacciona (no aparece el preview). +- **Rutas ABSOLUTAS obligatorias.** `setFileInputFiles` exige rutas absolutas; la funcion ya + convierte (`os.path.abspath` + expanduser), pero el archivo debe existir o aborta con ok=False + antes de abrir la conexion. +- **El selector debe resolver al input correcto.** Si hay varios `` (uno por + tipo: fotos, documento...), afina el selector (`[multiple]`, `[accept*="image"]`). Si no + matchea ninguno, devuelve `ok=False` con "no element matches selector". +- **Dispara `change`, no `input`.** `DOM.setFileInputFiles` emite el evento `change` nativo + (que la mayoria de uploads escuchan). Si una SPA solo escucha otro evento, no bastara. +- Requiere un Chrome lanzado con `--remote-debugging-port=9222` (o el puerto que pases). Sin + remote debugging, `GET /json` falla y devuelve `ok=False`. +- Nunca lanza: errores de archivo/red/WS/transport se reportan en `error` con `ok=False`. diff --git a/python/functions/browser/cdp_set_file_input.py b/python/functions/browser/cdp_set_file_input.py new file mode 100644 index 00000000..fc2a0deb --- /dev/null +++ b/python/functions/browser/cdp_set_file_input.py @@ -0,0 +1,163 @@ +"""Asigna archivos a un de una pestana de Chrome via Chrome DevTools Protocol. + +Primitiva de input CDP para subir archivos SIN abrir el dialogo nativo del sistema +operativo: localiza un target (pestana) por substring de su URL, abre el WebSocket de +depuracion y ejecuta la secuencia `DOM.enable` -> `DOM.getDocument` -> `DOM.querySelector` +(localiza el input por selector CSS) -> `DOM.setFileInputFiles` (asigna las rutas +absolutas al input). + +Es el unico metodo robusto para adjuntar archivos por CDP: el navegador no permite +escribir el `value` de un `` desde JavaScript (seguridad), y simular +drag&drop es fragil. `DOM.setFileInputFiles` inyecta los `File` directamente y dispara el +evento `change` que la SPA escucha. Base de `whatsapp_send_image` y de cualquier flujo de +subida de archivos sobre el navegador diario sin robar el foco al usuario. +""" + +import json +import os +import urllib.request + +import websocket + + +def _call(ws, msg_id: int, method: str, params: dict) -> dict: + """Envia un comando CDP y drena eventos hasta la respuesta con el mismo id.""" + ws.send(json.dumps({"id": msg_id, "method": method, "params": params})) + while True: + raw = ws.recv() + if not raw: + return {} + try: + parsed = json.loads(raw) + except Exception: # noqa: BLE001 — frame no-JSON, ignorar + continue + if parsed.get("id") == msg_id: + return parsed + + +def cdp_set_file_input( + selector: str, + file_paths, + *, + port: int = 9222, + target_url_substr: str = "", + timeout_s: float = 10.0, +) -> dict: + """Asigna uno o varios archivos a un `` de una pestana de Chrome. + + Localiza el target `page` por substring de URL, abre el WebSocket CDP y ejecuta + `DOM.enable` -> `DOM.getDocument` -> `DOM.querySelector(selector)` -> + `DOM.setFileInputFiles`. Equivale a que el usuario elija esos archivos en el dialogo + nativo, pero sin abrirlo: ideal para automatizar subidas (adjuntar imagen en WhatsApp, + subir un fichero a un formulario) sobre una pestana ya abierta. + + Args: + selector: Selector CSS del `` destino. Debe resolver a UN + elemento (se usa el primer match). El input puede estar oculto. + file_paths: Ruta (str) o lista de rutas a asignar. Se expanden (`~`) y se + convierten a rutas ABSOLUTAS; cada una debe existir en disco. + port: Puerto de remote debugging de Chrome. Default 9222. + target_url_substr: Substring que debe contener la URL del target (pestana). Si + "", usa el primer target de tipo "page". + timeout_s: Timeout (segundos) para la conexion WebSocket. Default 10.0. + + Returns: + dict con claves: + ok: bool — True si el input se localizo y los archivos se asignaron sin error. + error: str — mensaje de error (vacio si ok). + node_id: int — nodeId CDP del input localizado (0 si no se encontro). + selector: str — eco del selector usado. + files: list[str] — rutas absolutas que se intentaron asignar. + + Nunca lanza: errores de archivo, red, conexion o transport se devuelven en + "error" con ok=False. + """ + # 1. Normalizar y validar las rutas (antes de tocar la red). + if isinstance(file_paths, str): + raw_paths = [file_paths] + else: + raw_paths = list(file_paths) + abs_paths = [os.path.abspath(os.path.expanduser(p)) for p in raw_paths] + + if not abs_paths: + return {"ok": False, "error": "no file paths provided", + "node_id": 0, "selector": selector, "files": []} + missing = [p for p in abs_paths if not os.path.isfile(p)] + if missing: + return {"ok": False, "error": f"file(s) not found: {missing}", + "node_id": 0, "selector": selector, "files": abs_paths} + + # 2. Listar targets via HTTP y elegir el primer page que matchee. + try: + with urllib.request.urlopen( + f"http://127.0.0.1:{port}/json", timeout=5 + ) as resp: + targets = json.loads(resp.read().decode()) + except Exception as e: # noqa: BLE001 — red/HTTP/JSON, no relanzar + return {"ok": False, "error": str(e), + "node_id": 0, "selector": selector, "files": abs_paths} + + chosen = None + for t in targets: + if t.get("type") != "page": + continue + url = t.get("url", "") + if target_url_substr == "" or target_url_substr in url: + chosen = t + break + + if chosen is None: + return {"ok": False, "error": f"no target matching {target_url_substr}", + "node_id": 0, "selector": selector, "files": abs_paths} + + ws_url = chosen.get("webSocketDebuggerUrl", "") + + # 3. Abrir WS y correr la secuencia DOM. + try: + ws = websocket.create_connection(ws_url, timeout=timeout_s) + except Exception as e: # noqa: BLE001 — conexion WS + return {"ok": False, "error": str(e), + "node_id": 0, "selector": selector, "files": abs_paths} + + try: + _call(ws, 1, "DOM.enable", {}) + doc = _call(ws, 2, "DOM.getDocument", {"depth": 0}) + root_id = doc.get("result", {}).get("root", {}).get("nodeId", 0) + if not root_id: + return {"ok": False, "error": "DOM.getDocument did not return a root nodeId", + "node_id": 0, "selector": selector, "files": abs_paths} + + qs = _call(ws, 3, "DOM.querySelector", + {"nodeId": root_id, "selector": selector}) + node_id = qs.get("result", {}).get("nodeId", 0) + if not node_id: + return {"ok": False, "error": f"no element matches selector: {selector}", + "node_id": 0, "selector": selector, "files": abs_paths} + + sf = _call(ws, 4, "DOM.setFileInputFiles", + {"files": abs_paths, "nodeId": node_id}) + err = sf.get("error") + if err: + return {"ok": False, "error": json.dumps(err), + "node_id": node_id, "selector": selector, "files": abs_paths} + except Exception as e: # noqa: BLE001 — fallo de transport durante send/recv + return {"ok": False, "error": str(e), + "node_id": 0, "selector": selector, "files": abs_paths} + finally: + try: + ws.close() + except Exception: # noqa: BLE001 — cierre best-effort + pass + + return {"ok": True, "error": "", "node_id": node_id, + "selector": selector, "files": abs_paths} + + +if __name__ == "__main__": + import sys + + sel = sys.argv[1] if len(sys.argv) > 1 else 'input[type="file"]' + path = sys.argv[2] if len(sys.argv) > 2 else "" + substr = sys.argv[3] if len(sys.argv) > 3 else "" + out = cdp_set_file_input(sel, path, port=9222, target_url_substr=substr) + print(json.dumps(out, ensure_ascii=False, indent=2)) diff --git a/python/functions/browser/cdp_set_file_input_test.py b/python/functions/browser/cdp_set_file_input_test.py new file mode 100644 index 00000000..1c63d0ed --- /dev/null +++ b/python/functions/browser/cdp_set_file_input_test.py @@ -0,0 +1,144 @@ +"""Tests para cdp_set_file_input — mockean urlopen + create_connection. + +Mockean la capa de red de CDP: urllib.request.urlopen (lista de targets) y +websocket.create_connection (un fake que responde a cada comando DOM por su id, con un +nodeId de raiz, un nodeId de input y una respuesta vacia para setFileInputFiles). Asi NO +hace falta Chrome. Para la validacion de existencia de archivo se usan rutas reales +(__file__) y rutas inexistentes. +""" + +import json +import os +import sys +from contextlib import contextmanager + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from browser import cdp_set_file_input as mod # noqa: E402 +from browser.cdp_set_file_input import cdp_set_file_input # noqa: E402 + +_THIS = os.path.abspath(__file__) + + +class _FakeResp: + def __init__(self, payload): + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return json.dumps(self._payload).encode() + + +class _FakeWS: + """WebSocket falso que responde a cada comando DOM por method.""" + + def __init__(self, *, query_node_id=4242, set_error=None): + self.sent = [] + self._inbox = [] + self.closed = False + self.query_node_id = query_node_id + self.set_error = set_error + + def send(self, raw): + msg = json.loads(raw) + self.sent.append(msg) + mid = msg["id"] + method = msg["method"] + if method == "DOM.getDocument": + resp = {"id": mid, "result": {"root": {"nodeId": 1}}} + elif method == "DOM.querySelector": + resp = {"id": mid, "result": {"nodeId": self.query_node_id}} + elif method == "DOM.setFileInputFiles": + if self.set_error is not None: + resp = {"id": mid, "error": self.set_error} + else: + resp = {"id": mid, "result": {}} + else: # DOM.enable y demas + resp = {"id": mid, "result": {}} + self._inbox.append(json.dumps(resp)) + + def recv(self): + if self._inbox: + return self._inbox.pop(0) + return "" + + def close(self): + self.closed = True + + +@contextmanager +def _patch(targets, ws_obj=None, create_conn_exc=None): + orig_urlopen = mod.urllib.request.urlopen + orig_create = mod.websocket.create_connection + + def fake_urlopen(url, timeout=5): + return _FakeResp(targets) + + def fake_create(ws_url, timeout=10): + if create_conn_exc is not None: + raise create_conn_exc + return ws_obj + + mod.urllib.request.urlopen = fake_urlopen + mod.websocket.create_connection = fake_create + try: + yield + finally: + mod.urllib.request.urlopen = orig_urlopen + mod.websocket.create_connection = orig_create + + +_TARGETS = [ + {"type": "page", "url": "https://web.whatsapp.com/", "webSocketDebuggerUrl": "ws://x/1"}, +] + + +def test_golden_asigna_archivos_al_input(): + """setFileInputFiles recibe la ruta ABSOLUTA y el nodeId del input localizado.""" + ws = _FakeWS(query_node_id=777) + with _patch(_TARGETS, ws_obj=ws): + res = cdp_set_file_input('input[type="file"]', _THIS, target_url_substr="whatsapp") + + assert res["ok"] is True + assert res["error"] == "" + assert res["node_id"] == 777 + assert res["files"] == [_THIS] + # El ultimo comando es setFileInputFiles con la ruta absoluta y el nodeId del input. + setcmd = [m for m in ws.sent if m["method"] == "DOM.setFileInputFiles"][0] + assert setcmd["params"]["files"] == [_THIS] + assert setcmd["params"]["nodeId"] == 777 + assert ws.closed is True + + +def test_edge_archivo_inexistente_ok_false_sin_red(): + """Una ruta inexistente devuelve ok=False antes de abrir cualquier conexion.""" + res = cdp_set_file_input('input[type="file"]', "/no/existe/imagen.png", + target_url_substr="whatsapp") + assert res["ok"] is False + assert "not found" in res["error"] + assert res["node_id"] == 0 + + +def test_edge_selector_no_encontrado_ok_false(): + """Si querySelector devuelve nodeId 0, no se asignan archivos y ok=False.""" + ws = _FakeWS(query_node_id=0) + with _patch(_TARGETS, ws_obj=ws): + res = cdp_set_file_input('input[type="file"]', _THIS, target_url_substr="whatsapp") + assert res["ok"] is False + assert "no element matches selector" in res["error"] + # NO se llamo a setFileInputFiles. + assert all(m["method"] != "DOM.setFileInputFiles" for m in ws.sent) + + +def test_error_create_connection_lanza_ok_false(): + """Si create_connection lanza, se captura y devuelve ok=False sin relanzar.""" + with _patch(_TARGETS, create_conn_exc=ConnectionRefusedError("ws down")): + res = cdp_set_file_input('input[type="file"]', _THIS, target_url_substr="whatsapp") + assert res["ok"] is False + assert "ws down" in res["error"] + assert res["files"] == [_THIS] diff --git a/python/functions/browser/whatsapp_send_image.md b/python/functions/browser/whatsapp_send_image.md new file mode 100644 index 00000000..25be405a --- /dev/null +++ b/python/functions/browser/whatsapp_send_image.md @@ -0,0 +1,94 @@ +--- +name: whatsapp_send_image +kind: function +lang: py +domain: browser +version: "1.0.0" +purity: impure +signature: "def whatsapp_send_image(name: str, image_path: str, *, caption: str = '', port: int = 9222, target_url_substr: str = 'whatsapp', open_first: bool = True) -> dict" +description: "Envia una imagen (con caption opcional) a un chat de WhatsApp Web en una pestana ya logueada del navegador diario via CDP, sin abrir ventana nueva ni darle foco. Abre el chat por nombre exacto (whatsapp_open_chat) y verifica el destinatario (salvaguarda anti-envio-equivocado), hace click real en 'Adjuntar' para exponer el vivo, asigna la imagen con cdp_set_file_input (DOM.setFileInputFiles), espera el preview de la bandeja inline, teclea el caption opcional con teclado CDP real, y hace click en el boton enviar (icono wds-ic-send-filled) verificando que la bandeja se cerro. Accion con efecto: envia la imagen DE VERDAD, no reversible." +tags: [whatsapp, cdp, browser, automation, image, upload, python, navegator] +uses_functions: [whatsapp_open_chat_py_browser, cdp_eval_py_browser, cdp_click_xy_py_browser, cdp_type_chars_py_browser, cdp_set_file_input_py_browser] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["os", "sys", "time", "json"] +params_schema: + params: + - name: name + desc: "Nombre EXACTO del chat o 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." + - name: image_path + desc: "Ruta de la imagen a enviar. Se expande (~) y se convierte a ruta ABSOLUTA; debe existir en disco o aborta con error sin abrir el chat." + - name: caption + desc: "Texto opcional que acompana la imagen. Se teclea en el composer con teclado CDP real caracter a caracter; '' (default) envia la imagen sin caption." + - name: port + desc: "Puerto de remote debugging de Chrome. Default 9222." + - name: target_url_substr + desc: "Substring que debe contener la URL del target (pestana). Default 'whatsapp'." + - name: open_first + desc: "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)." + output: "dict {ok: bool (alias de sent), sent: bool, recipient: str, image: str (ruta absoluta), caption: str, error: str (motivo del fallo, vacio si sent=True)}. sent=True solo si la imagen se adjunto, el caption (si lo hay) se verifico y se pulso enviar dejando la bandeja vacia. Nunca lanza: los fallos se reportan en 'sent'/'ok' + 'error'." +tested: true +tests: ["test_golden_adjunta_caption_y_envia", "test_envia_sin_caption_no_teclea", "test_edge_imagen_no_existe_error_sin_abrir", "test_edge_open_fallido_error_sin_adjuntar", "test_seguridad_open_first_false_label_no_coincide_aborta", "test_error_set_file_input_falla_no_envia"] +test_file_path: "python/functions/browser/whatsapp_send_image_test.py" +file_path: "python/functions/browser/whatsapp_send_image.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from browser.whatsapp_send_image import whatsapp_send_image + +# Requiere WhatsApp Web abierto y logueado (y DESBLOQUEADO si tiene app-lock) en un +# Chrome lanzado con --remote-debugging-port=9222. +res = whatsapp_send_image( + "NOTAS WASAP", + "/home/enmanuel/ComfyUI/output/item_icon_potion_00001_.png", + caption="item icon: potion", +) +print(res) +# -> {"ok": True, "sent": True, "recipient": "NOTAS WASAP", +# "image": ".../item_icon_potion_00001_.png", "caption": "item icon: potion", "error": ""} +``` + +O directo por CLI: `python3 python/functions/browser/whatsapp_send_image.py "NOTAS WASAP" /ruta/abs.png "mi caption"`. + +## Cuando usarla + +Cuando necesites **enviar una imagen (foto, captura, asset generado) a un contacto o grupo +por su nombre exacto** en WhatsApp Web, sin abrir ventana nueva ni robar el foco al usuario. +Es la version "imagen" de `whatsapp_send_message_py_browser`: usala cuando ya tienes el +nombre exacto del destinatario y la ruta de un archivo de imagen en disco. Para texto plano, +usa `whatsapp_send_message`; para leer/confirmar lo enviado, `whatsapp_read_chat`. + +## Gotchas + +- **Accion con efecto: envia la imagen DE VERDAD.** No es reversible. Verifica que `name` es + EXACTO antes de llamar (la salvaguarda abre el chat y comprueba el composer, pero el nombre + debe coincidir con el `title` de la lista lateral). +- **App-lock de WhatsApp Web.** Si la cuenta tiene el "bloqueo de la aplicacion" activo, el DOM + de chats no se renderiza (solo la pantalla de password) y la funcion fallara al abrir el chat. + Hay que desbloquearlo primero (teclear el password en `input[type=password]` + boton + "Desbloquear"). Sintoma: `whatsapp_open_chat` devuelve `opened: False` y la lista lateral sale + vacia aunque la sesion siga logueada. +- **El input vivo solo existe tras pulsar "Adjuntar".** Por eso la funcion hace el click real en + el boton "Adjuntar" antes de `setFileInputFiles`; asignar al input persistente decoy no abre el + preview. La WhatsApp Web actual usa una **bandeja de medios INLINE** sobre el composer (no un + drawer a pantalla completa): el caption se escribe en el MISMO composer del footer. +- **Selector de aria-label en espanol.** El preview se detecta por `[aria-label="Quitar archivo + adjunto"]` y el boton de adjuntar por `[aria-label="Adjuntar"]`: dependen del idioma de la UI + (espanol). En otro locale habria que ajustar los aria-labels. +- **Las imagenes se ACUMULAN en la bandeja.** Cada `setFileInputFiles` anade una miniatura; si un + envio queda a medias, la siguiente llamada podria sumar a las pendientes. La funcion verifica que + la bandeja queda vacia tras enviar (adjuntos=0) para confirmar; si no se cierra, devuelve + `sent=False` con "envio incierto". +- **Salvaguarda anti-destinatario-equivocado**: con `open_first=True` abre y verifica el chat; con + `open_first=False` lee el aria-label del composer y aborta si no contiene `name`. +- **Caption verificado**: tras teclear, re-lee el `innerText` del composer y solo envia si coincide + EXACTAMENTE con `caption`; si no, devuelve `sent=False` sin enviar. +- **Funciona con la ventana minimizada o sin foco**: CDP opera la pestana sin traerla a primer plano. +- **Viola los ToS de WhatsApp**: automatizar la web tiene riesgo de ban del numero personal. Usar + con cautela y bajo tu responsabilidad. diff --git a/python/functions/browser/whatsapp_send_image.py b/python/functions/browser/whatsapp_send_image.py new file mode 100644 index 00000000..81f60a3e --- /dev/null +++ b/python/functions/browser/whatsapp_send_image.py @@ -0,0 +1,208 @@ +"""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)) diff --git a/python/functions/browser/whatsapp_send_image_test.py b/python/functions/browser/whatsapp_send_image_test.py new file mode 100644 index 00000000..4e9f6408 --- /dev/null +++ b/python/functions/browser/whatsapp_send_image_test.py @@ -0,0 +1,171 @@ +"""Tests para whatsapp_send_image. + +whatsapp_send_image compone whatsapp_open_chat con cuatro primitivas CDP (cdp_eval, +cdp_click_xy, cdp_type_chars, cdp_set_file_input) y requiere un Chrome vivo. Aqui se +mockean las cinco con monkeypatch sobre el modulo `browser.whatsapp_send_image` (donde +quedan ligados los nombres por el `from browser.X import Y`), de modo que NO hace falta +Chrome. + +El fake de cdp_eval distingue cada expresion por un marcador de comentario JS embebido en +ella (`/*ADJUNTAR*/`, `/*PREVIEW*/`, `/*COMPOSER*/`, `/*SEND*/`, `/*LABEL*/`). El estado +`after_send` (que /*SEND*/ activa) hace que /*PREVIEW*/ devuelva 0 adjuntos tras el envio, +simulando el cierre de la bandeja. +""" + +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import browser.whatsapp_send_image as wsi # noqa: E402 +from browser.whatsapp_send_image import whatsapp_send_image # noqa: E402 + +# Imagen real para pasar la validacion de existencia (este propio archivo de test). +_IMG = os.path.abspath(__file__) + + +class _Spy: + def __init__(self, ret=None): + self.calls = [] + self.ret = ret if ret is not None else {"ok": True} + + def __call__(self, *args, **kwargs): + self.calls.append((args, kwargs)) + return self.ret + + +def _make_eval(caption_value="", label="Escribir un mensaje para el grupo NOTAS WASAP", + state=None): + state = state if state is not None else {} + + def fake(expr, *, port=9222, target_url_substr=""): + if "/*ADJUNTAR*/" in expr: + return {"ok": True, "value": json.dumps({"x": 575, "y": 589}), "error": ""} + if "/*SEND*/" in expr: + state["after_send"] = True + return {"ok": True, "value": json.dumps({"x": 1136, "y": 578}), "error": ""} + if "/*PREVIEW*/" in expr: + return {"ok": True, "value": 0 if state.get("after_send") else 1, "error": ""} + if "/*COMPOSER*/" in expr: + return {"ok": True, "value": caption_value, "error": ""} + if "/*LABEL*/" in expr: + return {"ok": True, "value": label, "error": ""} + return {"ok": True, "value": None, "error": ""} + + return fake + + +def _patch_common(monkeypatch, *, eval_fn, set_ret={"ok": True}, open_ret=None): + open_spy = _Spy(ret=open_ret if open_ret is not None else {"opened": True, "name": "x"}) + click_spy = _Spy(ret={"ok": True}) + type_spy = _Spy(ret={"ok": True}) + set_spy = _Spy(ret=set_ret) + monkeypatch.setattr(wsi, "whatsapp_open_chat", open_spy) + monkeypatch.setattr(wsi, "cdp_eval", eval_fn) + monkeypatch.setattr(wsi, "cdp_click_xy", click_spy) + monkeypatch.setattr(wsi, "cdp_type_chars", type_spy) + monkeypatch.setattr(wsi, "cdp_set_file_input", set_spy) + monkeypatch.setattr(wsi.time, "sleep", lambda *a, **k: None) + return open_spy, click_spy, type_spy, set_spy + + +def test_golden_adjunta_caption_y_envia(monkeypatch): + cap = "item icon: potion" + state = {} + open_spy, click_spy, type_spy, set_spy = _patch_common( + monkeypatch, eval_fn=_make_eval(caption_value=cap, state=state)) + + res = whatsapp_send_image("NOTAS WASAP", _IMG, caption=cap, + port=9222, target_url_substr="whatsapp") + + assert res["sent"] is True and res["ok"] is True + assert res["recipient"] == "NOTAS WASAP" + assert res["image"] == _IMG + assert res["caption"] == cap + assert res["error"] == "" + # Se adjunto la imagen (ruta absoluta) por setFileInputFiles. + assert set_spy.calls[0][0][0] == 'input[type="file"][multiple]' + assert set_spy.calls[0][0][1] == _IMG + # Se tecleo el caption una vez. + assert len(type_spy.calls) == 1 + assert type_spy.calls[0][0][0] == cap + # Dos clicks reales: Adjuntar y Enviar. + assert len(click_spy.calls) == 2 + + +def test_envia_sin_caption_no_teclea(monkeypatch): + state = {} + _, click_spy, type_spy, set_spy = _patch_common( + monkeypatch, eval_fn=_make_eval(caption_value="", state=state)) + + res = whatsapp_send_image("NOTAS WASAP", _IMG, caption="", + port=9222, target_url_substr="whatsapp") + + assert res["sent"] is True + # Sin caption: NO se teclea nada, pero si se adjunta y se envia. + assert len(type_spy.calls) == 0 + assert len(set_spy.calls) >= 1 + assert len(click_spy.calls) == 2 + + +def test_edge_imagen_no_existe_error_sin_abrir(monkeypatch): + open_spy, click_spy, type_spy, set_spy = _patch_common( + monkeypatch, eval_fn=_make_eval()) + + res = whatsapp_send_image("NOTAS WASAP", "/no/existe/foo.png", + port=9222, target_url_substr="whatsapp") + + assert res["sent"] is False and res["ok"] is False + assert "no encontrada" in res["error"] + # Ni siquiera se intento abrir el chat ni adjuntar. + assert len(open_spy.calls) == 0 + assert len(set_spy.calls) == 0 + + +def test_edge_open_fallido_error_sin_adjuntar(monkeypatch): + open_spy, click_spy, type_spy, set_spy = _patch_common( + monkeypatch, eval_fn=_make_eval(), + open_ret={"opened": False, "name": "x", + "reason": "chat no encontrado en la lista (no cargado o nombre inexacto)"}) + + res = whatsapp_send_image("Inexistente", _IMG, + port=9222, target_url_substr="whatsapp") + + assert res["sent"] is False + assert "no encontrado" in res["error"] + # No se adjunto ni se hizo click cuando el chat no abrio. + assert len(set_spy.calls) == 0 + assert len(click_spy.calls) == 0 + + +def test_seguridad_open_first_false_label_no_coincide_aborta(monkeypatch): + open_spy, click_spy, type_spy, set_spy = _patch_common( + monkeypatch, + eval_fn=_make_eval(label="Escribir un mensaje para el grupo OTRO CHAT")) + + res = whatsapp_send_image("NOTAS WASAP", _IMG, + port=9222, target_url_substr="whatsapp", + open_first=False) + + assert res["sent"] is False + assert "abortado por seguridad" in res["error"] + # SEGURIDAD: no se adjunto ni se clicko nada. + assert len(set_spy.calls) == 0 + assert len(click_spy.calls) == 0 + + +def test_error_set_file_input_falla_no_envia(monkeypatch): + state = {} + open_spy, click_spy, type_spy, set_spy = _patch_common( + monkeypatch, eval_fn=_make_eval(state=state), + set_ret={"ok": False, "error": "no element matches selector"}) + + res = whatsapp_send_image("NOTAS WASAP", _IMG, + port=9222, target_url_substr="whatsapp") + + assert res["sent"] is False + assert "no se pudo adjuntar" in res["error"] + # Se intento adjuntar (dos selectores) pero no se llego a enviar (solo el click de Adjuntar). + assert len(set_spy.calls) == 2 + assert len(click_spy.calls) == 1