ahora si funciona

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 16:23:52 +02:00
parent d996542f88
commit 10bfb846a8
16 changed files with 1530 additions and 0 deletions
@@ -0,0 +1,69 @@
---
name: whatsapp_open_chat
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def whatsapp_open_chat(name: str, *, port: int = 9222, target_url_substr: str = 'whatsapp', wait_s: float = 1.3) -> dict"
description: "Abre un chat de WhatsApp Web por su nombre exacto en una pestana ya logueada del navegador diario via CDP, sin abrir ventana nueva ni darle foco. Busca por nombre, localiza el chat por su ancla estable span[title] dentro de #side, hace click de raton real y verifica que abrio leyendo el aria-label del composer. Base de whatsapp_read_chat y whatsapp_send_message."
tags: [whatsapp, cdp, browser, automation, python, navegator]
uses_functions: [cdp_eval_py_browser, cdp_type_chars_py_browser, cdp_press_key_py_browser, cdp_click_xy_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 tal y como aparece en la lista lateral (match exacto del atributo title del span ancla). Nombres ambiguos abren el primero que matchee."
- 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: wait_s
desc: "Segundos de espera tras teclear el nombre para que la lista lateral filtre y renderice los resultados. Default 1.3."
output: "dict {opened: bool, name: str, composer_label: str (si abrio), reason: str (si no abrio), coords: {x, y} (si encontro el ancla)}. opened=True si el nombre aparece en el aria-label del composer tras el click. Nunca lanza: los fallos se reportan en 'opened' + 'reason'."
tested: true
tests: ["test_golden_abre_chat_y_verifica_composer", "test_edge_ancla_no_encontrada_opened_false", "test_click_usa_coords_devueltas_por_el_ancla"]
test_file_path: "python/functions/browser/whatsapp_open_chat_test.py"
file_path: "python/functions/browser/whatsapp_open_chat.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.whatsapp_open_chat import whatsapp_open_chat
# Requiere WhatsApp Web abierto y logueado en un Chrome lanzado con
# --remote-debugging-port=9222.
res = whatsapp_open_chat("NOTAS WASAP")
print(res)
# -> {"opened": True,
# "name": "NOTAS WASAP",
# "composer_label": "Escribir un mensaje para el grupo NOTAS WASAP",
# "coords": {"x": 180, "y": 240}}
```
O directo por CLI: `python3 python/functions/browser/whatsapp_open_chat.py "NOTAS WASAP"`.
## Cuando usarla
Cuando necesites **abrir un chat concreto** de WhatsApp Web antes de leerlo
(`whatsapp_read_chat`) o de enviar un mensaje (`whatsapp_send_message`). Es el paso
base de ambas: el chat tiene que estar abierto (composer apuntando a el) para que las
otras funciones operen sobre la conversacion correcta. Util para automatizar el
navegador diario sin abrir ventana nueva ni robar el foco al usuario.
## Gotchas
- **Viola los ToS de WhatsApp**: automatizar la web tiene riesgo de ban del numero. Usar con cautela y bajo tu responsabilidad.
- El `name` debe ser **EXACTO** (match exacto de `span[title]`). Nombres ambiguos (varios chats que matchean) abren el primero que aparezca en la lista.
- El buscador **no filtra de forma fiable contactos NO cargados** en la lista lateral: funciona para chats recientes/visibles. Un contacto sin chat reciente puede no aparecer (limitacion conocida; futura mejora: scroll en la lista lateral antes de buscar).
- Usa **click de raton real** (`cdp_click_xy`). Un `element.click()` JS NO abre el chat porque los handlers de React no reaccionan a eventos sinteticos del DOM.
- **Funciona con la ventana minimizada o sin foco**: CDP opera la pestana sin necesidad de que Chrome este en primer plano.
- **`Escape` no limpia el buscador**: el texto se acumula entre llamadas. La funcion hace `input.select()` + `Backspace` antes de teclear el nombre nuevo.
- Si el ancla existe pero esta fuera del viewport (`b.y<0` o ancho 0), devuelve `opened=False` con `reason="chat fuera de viewport (scroll necesario)"` en vez de clicar a ciegas.
@@ -0,0 +1,135 @@
"""Abre un chat de WhatsApp Web en una pestana ya logueada via Chrome DevTools Protocol.
Compone cuatro primitivas CDP del registry (`cdp_eval`, `cdp_type_chars`,
`cdp_press_key`, `cdp_click_xy`) para localizar y abrir un chat por su nombre
exacto SIN abrir ventana nueva ni darle foco al sistema:
1. Limpia el buscador (`Escape` no basta: el texto se acumula -> select + Backspace).
2. Enfoca el input de busqueda y teclea el nombre caracter a caracter.
3. Localiza el chat por su ancla estable `span[title="<nombre exacto>"]` dentro
de `#side` y calcula el centro de su bounding box.
4. Hace un click de RATON REAL sobre esas coordenadas (un `element.click()` JS
no abre el chat: los handlers de React lo ignoran).
5. Verifica que abrio comprobando que el aria-label del composer contiene el nombre.
Base de `whatsapp_read_chat` y `whatsapp_send_message`: ambas necesitan el chat
abierto antes de leer o enviar.
"""
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_type_chars import cdp_type_chars
from browser.cdp_press_key import cdp_press_key
from browser.cdp_click_xy import cdp_click_xy
def _ev(expr: str, port: int, substr: str) -> dict:
"""Atajo: evalua una expresion JS en el target de WhatsApp."""
return cdp_eval(expr, port=port, target_url_substr=substr)
def whatsapp_open_chat(
name: str,
*,
port: int = 9222,
target_url_substr: str = "whatsapp",
wait_s: float = 1.3,
) -> dict:
"""Abre un chat de WhatsApp Web por su nombre exacto en una pestana logueada.
Args:
name: Nombre EXACTO del chat/grupo tal y como aparece en la lista lateral
(match exacto del atributo `title` del `span` ancla). Nombres ambiguos
abren el primero que matchee.
port: Puerto de remote debugging de Chrome. Default 9222.
target_url_substr: Substring que debe contener la URL del target (pestana).
Default "whatsapp".
wait_s: Segundos de espera tras teclear el nombre para que la lista lateral
filtre y renderice los resultados. Default 1.3.
Returns:
dict con claves:
opened: bool — True si el chat se abrio (el nombre aparece en el
aria-label del composer).
name: str — el nombre solicitado.
composer_label: str — aria-label del composer (solo si abrio).
reason: str — motivo del fallo (solo si no abrio).
coords: dict {x, y} — coordenadas del click (solo si encontro el ancla).
"""
substr = target_url_substr
# 1. Limpiar el buscador. Escape NO basta: el texto se acumula entre llamadas,
# asi que seleccionamos todo el contenido del input y lo borramos.
cdp_press_key("Escape", port=port, target_url_substr=substr)
time.sleep(0.3)
_ev(
"var i=document.querySelector('#side input'); if(i){i.focus(); i.select();}",
port,
substr,
)
cdp_press_key("Backspace", port=port, target_url_substr=substr)
time.sleep(0.2)
# 2. Enfocar el input de busqueda y teclear el nombre caracter a caracter.
_ev("var i=document.querySelector('#side input'); if(i){i.focus();}", port, substr)
time.sleep(0.2)
cdp_type_chars(name, port=port, target_url_substr=substr, delay_ms=15)
time.sleep(wait_s)
# 3. Localizar el ancla estable: span[title] con nombre EXACTO dentro de #side.
# Devuelve el centro del bounding box, o un marcador offscreen si no es visible.
expr = (
"(() => { const name=" + json.dumps(name) + ";"
"const a=[...document.querySelectorAll('#side span[title]')]"
".find(s=>s.getAttribute('title')===name);"
"if(!a) return null; const b=a.getBoundingClientRect();"
"if(b.width===0||b.y<0) return JSON.stringify({offscreen:true});"
"return JSON.stringify({x:Math.round(b.x+b.width/2),y:Math.round(b.y+b.height/2)});})()"
)
r = _ev(expr, port, substr)
if not r.get("value"):
return {
"opened": False,
"name": name,
"reason": "chat no encontrado en la lista (no cargado o nombre inexacto)",
}
c = json.loads(r["value"])
if c.get("offscreen"):
return {
"opened": False,
"name": name,
"reason": "chat fuera de viewport (scroll necesario)",
}
# 4. Click de raton real sobre el ancla. Un element.click() JS NO abre el chat
# porque los handlers de React no reaccionan a eventos sinteticos del DOM.
cdp_click_xy(c["x"], c["y"], port=port, target_url_substr=substr)
time.sleep(1.1)
# 5. Verificar: el composer (footer contenteditable) apunta al chat abierto.
chk = _ev(
"var b=document.querySelector('footer div[contenteditable=\"true\"]'); "
"b?b.getAttribute('aria-label'):null",
port,
substr,
)
label = chk.get("value") or ""
return {
"opened": name in label,
"name": name,
"composer_label": label,
"coords": c,
}
if __name__ == "__main__":
chat = sys.argv[1] if len(sys.argv) > 1 else "NOTAS WASAP"
out = whatsapp_open_chat(chat, port=9222, target_url_substr="whatsapp")
print(json.dumps(out, ensure_ascii=False, indent=2))
@@ -0,0 +1,112 @@
"""Tests para whatsapp_open_chat.
whatsapp_open_chat compone cuatro primitivas CDP (cdp_eval, cdp_type_chars,
cdp_press_key, cdp_click_xy) y requiere un Chrome vivo. Aqui se mockean las cuatro
con monkeypatch sobre el modulo `browser.whatsapp_open_chat` (donde quedan ligados
los nombres por el `from browser.X import Y`), de modo que NO hace falta Chrome.
Las llamadas a cdp_eval que importan son dos:
- la del ancla (querySelectorAll '#side span[title]') -> devuelve coords JSON.
- la de verificacion (footer contenteditable aria-label) -> devuelve el label.
El resto de cdp_eval (focus/select del input) devuelven un value inocuo.
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import browser.whatsapp_open_chat as woc
from browser.whatsapp_open_chat import whatsapp_open_chat
# --- Fakes -----------------------------------------------------------------
def _fake_cdp_eval_factory(anchor_value, composer_value):
"""Devuelve un fake de cdp_eval que distingue el ancla del composer.
- Expresion con 'span[title]' (busqueda del ancla) -> {"value": anchor_value}.
- Expresion con 'contenteditable' (composer) -> {"value": composer_value}.
- Cualquier otra (focus/select del input) -> {"value": None} inocuo.
"""
def _fake(expr, *, port=9222, target_url_substr=""):
if "span[title]" in expr:
return {"ok": True, "value": anchor_value, "error": "", "target_url": ""}
if "contenteditable" in expr:
return {"ok": True, "value": composer_value, "error": "", "target_url": ""}
return {"ok": True, "value": None, "error": "", "target_url": ""}
return _fake
class _Spy:
"""Registra los argumentos posicionales de cada llamada."""
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 _patch_io(monkeypatch, *, anchor_value, composer_value, click_spy=None):
"""Mockea las cuatro primitivas + time.sleep en el modulo woc."""
monkeypatch.setattr(woc, "cdp_eval",
_fake_cdp_eval_factory(anchor_value, composer_value))
monkeypatch.setattr(woc, "cdp_type_chars", lambda *a, **k: {"ok": True})
monkeypatch.setattr(woc, "cdp_press_key", lambda *a, **k: {"ok": True})
monkeypatch.setattr(woc, "cdp_click_xy", click_spy or (lambda *a, **k: {"ok": True}))
monkeypatch.setattr(woc.time, "sleep", lambda *a, **k: None)
# --- Tests -----------------------------------------------------------------
def test_golden_abre_chat_y_verifica_composer(monkeypatch):
coords = json.dumps({"x": 180, "y": 240})
label = "Escribir un mensaje para el grupo NOTAS WASAP"
_patch_io(monkeypatch, anchor_value=coords, composer_value=label)
res = whatsapp_open_chat("NOTAS WASAP", port=9222, target_url_substr="whatsapp")
assert res["opened"] is True
assert res["name"] == "NOTAS WASAP"
assert res["composer_label"] == label
assert res["coords"] == {"x": 180, "y": 240}
def test_edge_ancla_no_encontrada_opened_false(monkeypatch):
# El ancla no existe: cdp_eval del span[title] devuelve value None.
_patch_io(monkeypatch, anchor_value=None, composer_value="irrelevante")
res = whatsapp_open_chat("Contacto Inexistente", port=9222,
target_url_substr="whatsapp")
assert res["opened"] is False
assert res["name"] == "Contacto Inexistente"
assert "no encontrado" in res["reason"]
# Sin coords ni composer_label cuando no se encuentra el ancla.
assert "coords" not in res
assert "composer_label" not in res
def test_click_usa_coords_devueltas_por_el_ancla(monkeypatch):
coords = json.dumps({"x": 333, "y": 444})
label = "Escribir un mensaje para el grupo NOTAS WASAP"
click_spy = _Spy(ret={"ok": True})
_patch_io(monkeypatch, anchor_value=coords, composer_value=label,
click_spy=click_spy)
res = whatsapp_open_chat("NOTAS WASAP", port=9222, target_url_substr="whatsapp")
# Se llamo a cdp_click_xy exactamente una vez con las coords del ancla.
assert len(click_spy.calls) == 1
args, kwargs = click_spy.calls[0]
assert args[0] == 333
assert args[1] == 444
assert kwargs["port"] == 9222
assert kwargs["target_url_substr"] == "whatsapp"
assert res["opened"] is True
@@ -0,0 +1,69 @@
---
name: whatsapp_read_chat
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def whatsapp_read_chat(name: str, *, n: int = 15, port: int = 9222, target_url_substr: str = 'whatsapp', open_first: bool = True) -> dict"
description: "Lee los ultimos N mensajes de un chat de WhatsApp Web en una pestana ya logueada del navegador via CDP, sin abrir ventana nueva ni darle foco. Opcionalmente abre el chat primero con whatsapp_open_chat, luego extrae los ultimos n [role=row] del panel #main, normaliza su texto y detecta si cada mensaje es saliente por la presencia de .message-out. Compone whatsapp_open_chat + cdp_eval."
tags: [whatsapp, cdp, browser, automation, python, navegator]
uses_functions: [whatsapp_open_chat_py_browser, cdp_eval_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 tal y como aparece en la lista lateral. Se pasa a whatsapp_open_chat cuando open_first=True."
- name: n
desc: "Numero maximo de mensajes recientes a leer (los ultimos del viewport renderizado). Default 15."
- 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, abre el chat con whatsapp_open_chat antes de leer. Si el chat ya esta abierto, puede pasarse False para saltar la apertura. Default True."
output: "dict {ok: bool, name: str, messages: list[{text: str, outgoing: bool}], count: int (si ok), reason: str (si no ok)}. messages son los ultimos n mensajes renderizados, mas reciente al final. outgoing=True si el mensaje es saliente (.message-out). Nunca lanza: los fallos de apertura se reportan en 'ok'=False + 'reason'."
tested: true
tests: ["test_golden_lee_mensajes_y_detecta_outgoing", "test_edge_open_first_falla_no_lee_y_devuelve_reason", "test_open_first_false_no_llama_open_chat"]
test_file_path: "python/functions/browser/whatsapp_read_chat_test.py"
file_path: "python/functions/browser/whatsapp_read_chat.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.whatsapp_read_chat import whatsapp_read_chat
# Requiere WhatsApp Web abierto y logueado en un Chrome lanzado con
# --remote-debugging-port=9222.
res = whatsapp_read_chat("NOTAS WASAP", n=5)
print(res)
# -> {"ok": True,
# "name": "NOTAS WASAP",
# "messages": [{"text": "hola", "outgoing": False},
# {"text": "que tal", "outgoing": True}, ...],
# "count": 5}
```
O directo por CLI: `python3 python/functions/browser/whatsapp_read_chat.py "NOTAS WASAP" 5`.
## Cuando usarla
Cuando necesites **leer la conversacion reciente** de un chat de WhatsApp Web —
resumir el hilo, recuperar contexto, ver el ultimo mensaje recibido o saber quien
escribio que— sin abrir ventana nueva ni robar el foco al usuario. Pasa
`open_first=False` si el chat ya esta abierto para ahorrar el paso de apertura.
## Gotchas
- **Viola los ToS de WhatsApp**: automatizar la web tiene riesgo de ban del numero. Usar con cautela y bajo tu responsabilidad.
- **`outgoing` es heuristico**: se infiere de la presencia de `.message-out` en el row. Si WhatsApp cambia sus clases CSS, la deteccion entrante/saliente puede fallar.
- **Solo lee mensajes RENDERIZADOS en el viewport** del chat. Los mensajes muy antiguos requieren scroll del panel, que esta funcion NO implementa: solo recoge los ultimos `n` rows visibles del DOM.
- **Depende de `whatsapp_open_chat`** para localizar el chat (cuando `open_first=True`): hereda sus limitaciones (nombre EXACTO, contacto no cargado en la lista lateral puede no encontrarse, chat fuera de viewport). Si la apertura falla devuelve `ok=False` con `reason` y NO intenta leer.
- **Funciona con la ventana minimizada o sin foco**: CDP opera la pestana sin necesidad de que Chrome este en primer plano.
@@ -0,0 +1,89 @@
"""Lee los ultimos N mensajes de un chat de WhatsApp Web via Chrome DevTools Protocol.
Compone dos funciones del registry para extraer la conversacion reciente del chat
abierto en una pestana ya logueada del navegador, SIN abrir ventana nueva ni darle
foco al sistema:
1. (Opcional) Abre el chat por su nombre exacto con `whatsapp_open_chat`.
2. Evalua una expresion JS via `cdp_eval` que recoge los ultimos `n` `[role="row"]`
del panel principal (`#main`), normaliza su texto y detecta si cada mensaje es
saliente comprobando la presencia de `.message-out` en el row.
Devuelve la lista de mensajes mas recientes con su direccion (entrante/saliente).
"""
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.whatsapp_open_chat import whatsapp_open_chat
def whatsapp_read_chat(
name: str,
*,
n: int = 15,
port: int = 9222,
target_url_substr: str = "whatsapp",
open_first: bool = True,
) -> dict:
"""Lee los ultimos n mensajes renderizados de un chat de WhatsApp Web via CDP.
Args:
name: Nombre EXACTO del chat/grupo tal y como aparece en la lista lateral
(se pasa a `whatsapp_open_chat` cuando `open_first=True`).
n: Numero maximo de mensajes recientes a leer (los ultimos del viewport).
Default 15.
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, abre el chat con `whatsapp_open_chat` antes de leer.
Si el chat ya esta abierto y enfocado, puede pasarse False para ahorrar
el paso de apertura. Default True.
Returns:
dict con claves:
ok: bool — True si se pudo leer el chat.
name: str — el nombre solicitado.
messages: list[dict] — mensajes recientes, cada uno {text: str,
outgoing: bool}. Lista vacia si no hay mensajes o fallo la apertura.
count: int — numero de mensajes leidos (solo si ok).
reason: str — motivo del fallo (solo si ok=False).
"""
substr = target_url_substr
if open_first:
o = whatsapp_open_chat(name, port=port, target_url_substr=substr)
if not o.get("opened"):
return {
"ok": False,
"name": name,
"messages": [],
"reason": o.get("reason", "no se pudo abrir el chat"),
}
# Leer los ultimos n rows del panel principal. Detecta mensaje saliente por
# la presencia de .message-out en el row. Normaliza el texto (colapsa espacios)
# y lo trunca a 500 caracteres para acotar el payload.
expr = (
"(() => { const rows=[...document.querySelectorAll('#main [role=\"row\"]')]"
".slice(-" + str(int(n)) + ");"
"return JSON.stringify(rows.map(r=>({"
"text: r.innerText.replace(/\\s+/g,' ').trim().slice(0,500),"
"outgoing: !!r.querySelector('.message-out')"
"})));})()"
)
r = cdp_eval(expr, port=port, target_url_substr=substr)
msgs = json.loads(r.get("value") or "[]")
return {"ok": True, "name": name, "messages": msgs, "count": len(msgs)}
if __name__ == "__main__":
chat = sys.argv[1] if len(sys.argv) > 1 else "NOTAS WASAP"
count = int(sys.argv[2]) if len(sys.argv) > 2 else 15
out = whatsapp_read_chat(chat, n=count, port=9222, target_url_substr="whatsapp")
print(json.dumps(out, ensure_ascii=False, indent=2))
@@ -0,0 +1,95 @@
"""Tests para whatsapp_read_chat.
whatsapp_read_chat compone whatsapp_open_chat (apertura del chat) + cdp_eval
(lectura de los rows del panel #main) y requiere un Chrome vivo. Aqui se mockean
ambas con monkeypatch sobre el modulo `browser.whatsapp_read_chat` (donde quedan
ligados los nombres por el `from browser.X import Y`), de modo que NO hace falta
Chrome.
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import browser.whatsapp_read_chat as wrc
from browser.whatsapp_read_chat import whatsapp_read_chat
class _Spy:
"""Registra cada llamada (args, kwargs) y devuelve un valor fijo."""
def __init__(self, ret):
self.calls = []
self.ret = ret
def __call__(self, *args, **kwargs):
self.calls.append((args, kwargs))
return self.ret
# --- Tests -----------------------------------------------------------------
def test_golden_lee_mensajes_y_detecta_outgoing(monkeypatch):
# whatsapp_open_chat abre el chat OK.
open_spy = _Spy(ret={"opened": True, "name": "NOTAS WASAP"})
# cdp_eval devuelve el JSON serializado de dos mensajes (uno entrante, uno saliente).
rows = json.dumps([
{"text": "hola", "outgoing": False},
{"text": "que tal", "outgoing": True},
])
eval_spy = _Spy(ret={"ok": True, "value": rows, "error": "", "target_url": ""})
monkeypatch.setattr(wrc, "whatsapp_open_chat", open_spy)
monkeypatch.setattr(wrc, "cdp_eval", eval_spy)
res = whatsapp_read_chat("NOTAS WASAP", n=2, port=9222,
target_url_substr="whatsapp")
assert res["ok"] is True
assert res["name"] == "NOTAS WASAP"
assert res["count"] == 2
assert res["messages"] == [
{"text": "hola", "outgoing": False},
{"text": "que tal", "outgoing": True},
]
# Se abrio el chat una vez y se leyo una vez.
assert len(open_spy.calls) == 1
assert len(eval_spy.calls) == 1
def test_edge_open_first_falla_no_lee_y_devuelve_reason(monkeypatch):
# whatsapp_open_chat NO consigue abrir el chat.
open_spy = _Spy(ret={
"opened": False,
"name": "Contacto Inexistente",
"reason": "chat no encontrado en la lista (no cargado o nombre inexacto)",
})
eval_spy = _Spy(ret={"ok": True, "value": "[]", "error": "", "target_url": ""})
monkeypatch.setattr(wrc, "whatsapp_open_chat", open_spy)
monkeypatch.setattr(wrc, "cdp_eval", eval_spy)
res = whatsapp_read_chat("Contacto Inexistente", open_first=True)
assert res["ok"] is False
assert res["name"] == "Contacto Inexistente"
assert res["messages"] == []
assert "no encontrado" in res["reason"]
# Como la apertura fallo, NO se llamo al cdp_eval de lectura.
assert len(eval_spy.calls) == 0
def test_open_first_false_no_llama_open_chat(monkeypatch):
# Con open_first=False no se debe invocar whatsapp_open_chat; se lee directo.
open_spy = _Spy(ret={"opened": True})
rows = json.dumps([{"text": "ya estaba abierto", "outgoing": False}])
eval_spy = _Spy(ret={"ok": True, "value": rows, "error": "", "target_url": ""})
monkeypatch.setattr(wrc, "whatsapp_open_chat", open_spy)
monkeypatch.setattr(wrc, "cdp_eval", eval_spy)
res = whatsapp_read_chat("NOTAS WASAP", n=1, open_first=False)
assert res["ok"] is True
assert res["count"] == 1
assert len(open_spy.calls) == 0
assert len(eval_spy.calls) == 1
@@ -0,0 +1,69 @@
---
name: whatsapp_send_message
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "def whatsapp_send_message(name: str, text: str, *, port: int = 9222, target_url_substr: str = 'whatsapp', open_first: bool = True) -> dict"
description: "Envia un mensaje de texto 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), verifica que el composer apunta al destinatario correcto antes de escribir (salvaguarda anti-envio-equivocado), teclea el texto con teclado CDP real (unico metodo que funciona con el editor Lexical), comprueba que el composer tiene exactamente el texto y envia con Enter. Accion con efecto: envia un mensaje DE VERDAD, no reversible."
tags: [whatsapp, cdp, browser, automation, python, navegator]
uses_functions: [whatsapp_open_chat_py_browser, cdp_eval_py_browser, cdp_type_chars_py_browser, cdp_press_key_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 escribir."
- name: text
desc: "Texto a enviar. Se teclea con teclado CDP real caracter a caracter. Enter lo envia (no inserta salto de linea); multilinea no soportado."
- 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 enviar. Si False, asume el chat ya abierto pero verifica el aria-label del composer contra name antes de escribir (aborta si no coincide)."
output: "dict {sent: bool, name: str, last_row: str (texto de la ultima fila de #main tras enviar, si sent=True), reason: str (motivo del fallo, si sent=False), composer: str (contenido real del composer cuando hubo mismatch de texto)}. sent=True solo si el composer contenia exactamente el texto y se pulso Enter. Nunca lanza: los fallos se reportan en 'sent' + 'reason'."
tested: true
tests: ["test_golden_envia_mensaje_y_devuelve_last_row", "test_edge_open_fallido_sent_false_reason", "test_seguridad_open_first_false_label_no_coincide_aborta_sin_escribir", "test_mismatch_composer_sent_false_sin_press_enter"]
test_file_path: "python/functions/browser/whatsapp_send_message_test.py"
file_path: "python/functions/browser/whatsapp_send_message.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from browser.whatsapp_send_message import whatsapp_send_message
# Requiere WhatsApp Web abierto y logueado en un Chrome lanzado con
# --remote-debugging-port=9222.
res = whatsapp_send_message("NOTAS WASAP", "hola desde el registry")
print(res)
# -> {"sent": True, "name": "NOTAS WASAP", "last_row": "hola desde el registry 11:40"}
```
O directo por CLI: `python3 python/functions/browser/whatsapp_send_message.py "NOTAS WASAP" "hola desde el registry"`.
## Cuando usarla
Cuando necesites **enviar un texto a un contacto o grupo por su nombre exacto** en
WhatsApp Web, sin abrir ventana nueva ni robar el foco al usuario. Compone
`whatsapp_open_chat` (abre y localiza el chat) con las primitivas CDP de teclado para
escribir y enviar. Es el paso de "envio" del navegador diario: usala cuando ya tienes
el nombre exacto del destinatario y un texto que mandar.
## Gotchas
- **Accion con efecto: envia un mensaje DE VERDAD.** No es reversible (WhatsApp no permite des-enviar por API ni por CDP). Verifica que `name` es EXACTO antes de llamar.
- **Viola los ToS de WhatsApp**: automatizar la web tiene riesgo de ban del numero personal. Usar con cautela y bajo tu responsabilidad.
- **Salvaguarda anti-destinatario-equivocado**: antes de escribir, verifica que el composer apunta a `name` (via `whatsapp_open_chat` con `open_first=True`, o leyendo el aria-label con `open_first=False`). Si no coincide, aborta con `sent=False` sin teclear nada.
- **Doble salvaguarda de contenido**: tras teclear, re-lee el `innerText` del composer y solo pulsa Enter si coincide EXACTAMENTE con `text`. Si no, devuelve `sent=False`, `reason` y el `composer` real, sin enviar.
- **El texto se escribe con teclado CDP real** (`cdp_type_chars`). NO usar `execCommand`/`el.value`: el editor Lexical de WhatsApp los ignora y produce texto duplicado/intercalado (gotcha real observado en pruebas).
- **`Enter` envia** (no inserta salto de linea). Para multilinea habria que usar Shift+Enter (no implementado aqui).
- **Funciona con la ventana minimizada o sin foco**: CDP opera la pestana sin necesidad de que Chrome este en primer plano.
- El `name` debe ser **EXACTO**; un contacto no cargado en la lista lateral puede no encontrarse al abrir (ver gotchas de `whatsapp_open_chat`).
@@ -0,0 +1,141 @@
"""Envia un mensaje de texto a un chat de WhatsApp Web via Chrome DevTools Protocol.
Compone `whatsapp_open_chat` (abrir + localizar el chat por nombre) con tres
primitivas CDP del registry (`cdp_eval`, `cdp_type_chars`, `cdp_press_key`) para
enviar un texto a un contacto/grupo SIN abrir ventana nueva ni darle foco al sistema.
Flujo, con dos salvaguardas 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 ya abierto pero VERIFICA que el
aria-label del composer contiene el nombre; si no, aborta por seguridad.
2. Enfoca el composer (`footer div[contenteditable="true"]`) y teclea el texto
con teclado CDP real (`cdp_type_chars`). NO se usa `execCommand`/`el.value`:
el editor Lexical de WhatsApp los ignora y produce texto duplicado/intercalado.
3. Re-lee el `innerText` del composer y comprueba que coincide EXACTAMENTE con el
texto pedido antes de enviar. Si no coincide, aborta sin pulsar Enter.
4. Pulsa `Enter` para enviar y devuelve la ultima fila renderizada de `#main`.
Validado contra WhatsApp Web real. Base para automatizar el envio de mensajes
sobre el navegador diario sin robar el foco al usuario.
"""
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_type_chars import cdp_type_chars
from browser.cdp_press_key import cdp_press_key
from browser.whatsapp_open_chat import whatsapp_open_chat
def whatsapp_send_message(
name: str,
text: str,
*,
port: int = 9222,
target_url_substr: str = "whatsapp",
open_first: bool = True,
) -> dict:
"""Envia un mensaje de texto a un chat de WhatsApp Web en una pestana logueada.
Accion CON EFECTO: envia un mensaje 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 escribir.
text: Texto a enviar. Se teclea con teclado CDP real caracter a caracter.
`Enter` lo envia (no inserta salto de linea); multilinea no soportado.
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 enviar.
Si False, asume el chat ya abierto pero verifica el aria-label del
composer contra `name` antes de escribir (aborta si no coincide).
Returns:
dict con claves:
sent: bool — True si el mensaje se envio.
name: str — el nombre solicitado.
last_row: str — texto de la ultima fila renderizada de #main tras
enviar (solo si sent=True).
reason: str — motivo del fallo (solo si sent=False).
composer: str — contenido real del composer cuando hubo mismatch
(solo si sent=False por texto inesperado).
Nunca lanza: los fallos se reportan en "sent" + "reason".
"""
S = target_url_substr
# 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 {
"sent": False,
"name": name,
"reason": o.get("reason", "no se pudo abrir el chat"),
}
else:
chk = cdp_eval(
"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 {
"sent": False,
"name": name,
"reason": "el chat abierto no coincide con el destinatario; abortado por seguridad",
}
# 2. Enfocar el composer y escribir con teclado real (NO execCommand: rompe Lexical).
cdp_eval(
"var b=document.querySelector('footer div[contenteditable=\"true\"]'); "
"if(b){b.focus();}",
port=port,
target_url_substr=S,
)
time.sleep(0.25)
cdp_type_chars(text, port=port, target_url_substr=S, delay_ms=15)
time.sleep(0.3)
# 3. Verificar que el composer tiene EXACTAMENTE el texto antes de enviar.
chk = cdp_eval(
"var b=document.querySelector('footer div[contenteditable=\"true\"]'); "
"b?b.innerText.replace(/\\n/g,''):''",
port=port,
target_url_substr=S,
)
composer = chk.get("value") or ""
if composer != text:
return {
"sent": False,
"name": name,
"reason": "el composer no contiene el texto esperado (no enviado)",
"composer": composer,
}
# 4. Enviar (Enter) y confirmar leyendo la ultima fila de #main.
cdp_press_key("Enter", port=port, target_url_substr=S)
time.sleep(0.7)
last = cdp_eval(
"var r=[...document.querySelectorAll('#main [role=\"row\"]')].slice(-1)[0]; "
"r?r.innerText.replace(/\\s+/g,' ').trim().slice(0,200):null",
port=port,
target_url_substr=S,
)
return {"sent": True, "name": name, "last_row": last.get("value")}
if __name__ == "__main__":
chat = sys.argv[1] if len(sys.argv) > 1 else "NOTAS WASAP"
msg = sys.argv[2] if len(sys.argv) > 2 else "hola desde el registry"
out = whatsapp_send_message(chat, msg, port=9222, target_url_substr="whatsapp")
print(json.dumps(out, ensure_ascii=False, indent=2))
@@ -0,0 +1,161 @@
"""Tests para whatsapp_send_message.
whatsapp_send_message compone whatsapp_open_chat con tres primitivas CDP
(cdp_eval, cdp_type_chars, cdp_press_key) y requiere un Chrome vivo. Aqui se
mockean las cuatro con monkeypatch sobre el modulo `browser.whatsapp_send_message`
(donde quedan ligados los nombres por el `from browser.X import Y`), de modo que
NO hace falta Chrome.
Las llamadas a cdp_eval que importan distinguen por el contenido de la expresion:
- 'aria-label' -> verificacion del destinatario (open_first=False).
- 'innerText' (composer) -> contenido tecleado, comparado contra el texto.
- 'role="row"' (#main) -> ultima fila renderizada tras enviar (last_row).
- cualquier otra (focus del composer) -> value inocuo.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import browser.whatsapp_send_message as wsm
from browser.whatsapp_send_message import whatsapp_send_message
# --- Fakes -----------------------------------------------------------------
def _fake_cdp_eval_factory(*, label_value=None, composer_value="", last_row_value=None):
"""Devuelve un fake de cdp_eval que distingue cada expresion por su contenido."""
def _fake(expr, *, port=9222, target_url_substr=""):
if "aria-label" in expr:
return {"ok": True, "value": label_value, "error": "", "target_url": ""}
if "innerText.replace(/\\n/g" in expr or "innerText.replace(/\n/g" in expr:
return {"ok": True, "value": composer_value, "error": "", "target_url": ""}
if 'role=\\"row\\"' in expr or 'role="row"' in expr:
return {"ok": True, "value": last_row_value, "error": "", "target_url": ""}
return {"ok": True, "value": None, "error": "", "target_url": ""}
return _fake
class _Spy:
"""Registra los argumentos de cada llamada y devuelve un valor fijo."""
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
# --- Tests -----------------------------------------------------------------
def test_golden_envia_mensaje_y_devuelve_last_row(monkeypatch):
text = "hola desde el registry"
last = "hola desde el registry 11:40"
# open_first=True (default): whatsapp_open_chat abre con exito.
monkeypatch.setattr(wsm, "whatsapp_open_chat",
lambda *a, **k: {"opened": True, "name": a[0]})
# composer devuelve exactamente el texto; #main devuelve la ultima fila.
monkeypatch.setattr(wsm, "cdp_eval",
_fake_cdp_eval_factory(composer_value=text, last_row_value=last))
type_spy = _Spy(ret={"ok": True})
press_spy = _Spy(ret={"ok": True})
monkeypatch.setattr(wsm, "cdp_type_chars", type_spy)
monkeypatch.setattr(wsm, "cdp_press_key", press_spy)
monkeypatch.setattr(wsm.time, "sleep", lambda *a, **k: None)
res = whatsapp_send_message("NOTAS WASAP", text,
port=9222, target_url_substr="whatsapp")
assert res["sent"] is True
assert res["name"] == "NOTAS WASAP"
assert res["last_row"] == last
# Se tecleo el texto y se pulso Enter una vez.
assert len(type_spy.calls) == 1
assert type_spy.calls[0][0][0] == text
assert len(press_spy.calls) == 1
assert press_spy.calls[0][0][0] == "Enter"
def test_edge_open_fallido_sent_false_reason(monkeypatch):
# whatsapp_open_chat no abre: aborta sin tocar el composer.
monkeypatch.setattr(
wsm, "whatsapp_open_chat",
lambda *a, **k: {"opened": False, "name": a[0],
"reason": "chat no encontrado en la lista (no cargado o nombre inexacto)"},
)
type_spy = _Spy(ret={"ok": True})
press_spy = _Spy(ret={"ok": True})
monkeypatch.setattr(wsm, "cdp_eval", _fake_cdp_eval_factory())
monkeypatch.setattr(wsm, "cdp_type_chars", type_spy)
monkeypatch.setattr(wsm, "cdp_press_key", press_spy)
monkeypatch.setattr(wsm.time, "sleep", lambda *a, **k: None)
res = whatsapp_send_message("Contacto Inexistente", "hola",
port=9222, target_url_substr="whatsapp")
assert res["sent"] is False
assert res["name"] == "Contacto Inexistente"
assert "no encontrado" in res["reason"]
# No se intento escribir ni enviar cuando el chat no abrio.
assert len(type_spy.calls) == 0
assert len(press_spy.calls) == 0
assert "last_row" not in res
def test_seguridad_open_first_false_label_no_coincide_aborta_sin_escribir(monkeypatch):
# open_first=False y el aria-label del composer NO contiene el name -> abort.
monkeypatch.setattr(wsm, "whatsapp_open_chat",
lambda *a, **k: {"opened": True, "name": a[0]})
monkeypatch.setattr(
wsm, "cdp_eval",
_fake_cdp_eval_factory(label_value="Escribir un mensaje para el grupo OTRO CHAT"),
)
type_spy = _Spy(ret={"ok": True})
press_spy = _Spy(ret={"ok": True})
monkeypatch.setattr(wsm, "cdp_type_chars", type_spy)
monkeypatch.setattr(wsm, "cdp_press_key", press_spy)
monkeypatch.setattr(wsm.time, "sleep", lambda *a, **k: None)
res = whatsapp_send_message("NOTAS WASAP", "hola",
port=9222, target_url_substr="whatsapp",
open_first=False)
assert res["sent"] is False
assert res["name"] == "NOTAS WASAP"
assert "abortado por seguridad" in res["reason"]
# SEGURIDAD: no se llamo a cdp_type_chars ni a cdp_press_key.
assert len(type_spy.calls) == 0
assert len(press_spy.calls) == 0
def test_mismatch_composer_sent_false_sin_press_enter(monkeypatch):
# El composer no contiene el texto esperado tras teclear -> no se envia.
text = "hola desde el registry"
composer_real = "holaa desde ell registryy" # texto distinto (Lexical duplicando)
monkeypatch.setattr(wsm, "whatsapp_open_chat",
lambda *a, **k: {"opened": True, "name": a[0]})
monkeypatch.setattr(wsm, "cdp_eval",
_fake_cdp_eval_factory(composer_value=composer_real))
type_spy = _Spy(ret={"ok": True})
press_spy = _Spy(ret={"ok": True})
monkeypatch.setattr(wsm, "cdp_type_chars", type_spy)
monkeypatch.setattr(wsm, "cdp_press_key", press_spy)
monkeypatch.setattr(wsm.time, "sleep", lambda *a, **k: None)
res = whatsapp_send_message("NOTAS WASAP", text,
port=9222, target_url_substr="whatsapp")
assert res["sent"] is False
assert res["name"] == "NOTAS WASAP"
assert "no contiene el texto esperado" in res["reason"]
assert res["composer"] == composer_real
# Se tecleo pero NO se pulso Enter por el mismatch.
assert len(type_spy.calls) == 1
assert len(press_spy.calls) == 0