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