2ff111bae4
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
237 lines
10 KiB
Python
237 lines
10 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`) 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 `<input type=file>` 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 <input type=file> (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))
|