"""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`) y `whatsapp_send_message` 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. ASEGURA que el menu "Adjuntar" esta ABIERTO (es un toggle `aria-expanded`: clickar
cuando ya esta abierto lo cierra). Solo entonces el `` queda "vivo".
3. Asigna la imagen al input via `cdp_set_file_input` (`DOM.setFileInputFiles`): la
imagen aparece como miniatura en la bandeja inline.
4. Espera a que la bandeja aparezca (boton "Quitar archivo adjunto" presente) y hace click
real en el boton de enviar la bandeja (icono `wds-ic-send-filled`).
5. Verifica el envio comprobando que la bandeja se cerro Y que la ultima fila de `#main`
es ahora una imagen (las filas se virtualizan, asi que NO sirve contar filas).
6. Si `caption` no esta vacio, lo envia como un MENSAJE DE TEXTO de seguimiento via
`whatsapp_send_message` (con `open_first=False`, el chat ya esta abierto). En la
WhatsApp Web compacta actual el caption embebido en la imagen no es automatizable de
forma fiable, asi que la descripcion viaja como una segunda burbuja: [imagen][caption].
Validado contra WhatsApp Web real. Accion CON EFECTO REAL E IRREVERSIBLE: envia la imagen
(y el caption) 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_set_file_input import cdp_set_file_input
from browser.whatsapp_open_chat import whatsapp_open_chat
from browser.whatsapp_send_message import whatsapp_send_message
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 _adj_expanded(port: int, substr: str) -> str:
"""Estado aria-expanded del boton 'Adjuntar' ('true'/'false'/'no-btn')."""
r = cdp_eval(
'/*ADJEXP*/var e=document.querySelector(\'button[aria-label="Adjuntar"]\'); '
"e?e.getAttribute('aria-expanded'):'no-btn'",
port=port, target_url_substr=substr,
)
return r.get("value") or "no-btn"
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 _last_row_is_image(port: int, substr: str) -> bool:
"""True si la ultima fila renderizada de #main contiene una imagen (blob)."""
r = cdp_eval(
'/*LASTIMG*/(() => {const r=[...document.querySelectorAll(\'#main [role="row"]\')]'
".slice(-1)[0]; return r?!!r.querySelector('img[src^=\"blob:\"]'):false;})()",
port=port, target_url_substr=substr,
)
return bool(r.get("value"))
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 de seguimiento) a un chat de WhatsApp Web.
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 descriptivo. Se envia como un MENSAJE DE TEXTO de seguimiento
(segunda burbuja [imagen][caption]) via `whatsapp_send_message`; "" (default)
envia solo la imagen.
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 — True si la imagen se envio y (si habia caption) el caption tambien.
sent: bool — True si la IMAGEN se envio.
caption_sent: bool — True si el caption de seguimiento se envio (False si no
habia caption o si fallo).
recipient: str — el nombre solicitado.
image: str — ruta absoluta de la imagen.
caption: str — caption solicitado.
error: str — motivo del fallo (vacio si todo ok).
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, "caption_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. Asegurar el menu "Adjuntar" ABIERTO. Es un TOGGLE (aria-expanded): clickar cuando
# ya esta abierto lo cierra y el input vivo desaparece. Por eso clickamos SOLO si no
# esta expandido y reintentamos hasta verlo abierto.
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")
for _ in range(3):
if _adj_expanded(port, S) == "true":
break
cdp_click_xy(adj["x"], adj["y"], port=port, target_url_substr=S)
time.sleep(0.6)
if _adj_expanded(port, S) != "true":
return fail("no se pudo abrir el menu 'Adjuntar' (aria-expanded sigue en false)")
# 3. Asignar la imagen al primer (el "vivo" mientras el menu esta
# abierto). El composer queda VACIO, asi que luego el unico wds-ic-send-filled es el
# de enviar la bandeja.
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 la 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")
time.sleep(0.3)
# 5. Click real en el boton de enviar la bandeja (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)
# 6. Confirmar envio: la bandeja se cierra (adjuntos=0) Y la ultima fila de #main es ya
# una imagen. Las filas de #main se VIRTUALIZAN, asi que contar filas no sirve.
image_sent = False
for _ in range(20):
time.sleep(0.2)
if _attachment_count(port, S) == 0 and _last_row_is_image(port, S):
image_sent = True
break
if not image_sent:
return fail("no se confirmo la imagen en el chat tras pulsar enviar; envio incierto")
# 7. Caption opcional como mensaje de texto de seguimiento (segunda burbuja).
caption_sent = False
if caption:
m = whatsapp_send_message(name, caption, port=port, target_url_substr=S,
open_first=False)
caption_sent = bool(m.get("sent"))
if not caption_sent:
return {"ok": False, "sent": True, "caption_sent": False, "recipient": name,
"image": abs_img, "caption": caption,
"error": "imagen enviada pero el caption fallo: " + m.get("reason", "")}
return {"ok": True, "sent": True, "caption_sent": caption_sent, "recipient": name,
"image": abs_img, "caption": caption, "error": ""}
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))