Files
fn_registry/python/functions/browser/whatsapp_send_image.py
T
egutierrez 4c4eec4b1d feat(browser): auto-commit con 6 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 23:09:32 +02:00

209 lines
8.9 KiB
Python

"""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
`<input type=file>` "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 <input type=file> 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))