diff --git a/docs/capabilities/INDEX.md b/docs/capabilities/INDEX.md index cebdb59c..7c035680 100644 --- a/docs/capabilities/INDEX.md +++ b/docs/capabilities/INDEX.md @@ -39,6 +39,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu | [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output | | [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs | | [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI | +| [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E | | [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers | | [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit | | [backends](backends.md) | — | Stacks backend (Go net/http+SQLite default, MCP, mautrix, bubbletea, httpx, docker-compose): decision tree + esqueleto canonico + funciones del registry a componer | diff --git a/docs/capabilities/whatsapp.md b/docs/capabilities/whatsapp.md new file mode 100644 index 00000000..156fe04e --- /dev/null +++ b/docs/capabilities/whatsapp.md @@ -0,0 +1,80 @@ +# WhatsApp — Operar WhatsApp Web por CDP sobre la sesión existente + +Tag: `whatsapp`. Grupo de funciones para automatizar WhatsApp Web (buscar/abrir un chat, +leer la conversación, enviar texto) operando por Chrome DevTools Protocol sobre la **pestaña +ya abierta y logueada** del navegador diario, **sin abrir ventana nueva ni darle foco**. + +Filtro MCP: `mcp__registry__fn_search query="" tag="whatsapp"`. + +## Por qué CDP y no HTTP replay + +WhatsApp Web **no envía mensajes por HTTP requests REST**: usa un **WebSocket** (wss) como +transporte y **cifrado extremo a extremo (Signal/Noise)**, con claves que rotan por mensaje y +viven en el navegador. El tráfico capturable es binario cifrado e irreproducible — por eso el +patrón `flow-replay` (grabar HTTP → reproducir) **no aplica** aquí. La única vía que opera la +sesión existente sin ventana nueva es **automatizar el DOM por CDP**. (Baileys/whatsapp-web.js +quedan descartados: emparejan un dispositivo nuevo por QR, o abren su propio navegador.) + +## Funciones del grupo + +| ID | Firma corta | Qué hace | +|---|---|---| +| [whatsapp_open_chat_py_browser](../../python/functions/browser/whatsapp_open_chat.md) | `whatsapp_open_chat(name, *, port=9222) -> dict` | Busca y abre un chat por nombre exacto (ancla `span[title]` + click de ratón real). Verifica el destinatario. Base de read/send. | +| [whatsapp_read_chat_py_browser](../../python/functions/browser/whatsapp_read_chat.md) | `whatsapp_read_chat(name, *, n=15, open_first=True) -> dict` | Lee los últimos N mensajes renderizados del chat (`{text, outgoing}`). | +| [whatsapp_send_message_py_browser](../../python/functions/browser/whatsapp_send_message.md) | `whatsapp_send_message(name, text, *, open_first=True) -> dict` | Envía un texto. Salvaguarda: verifica destinatario + contenido exacto del composer antes de pulsar Enter. | + +### Primitivas CDP que componen (grupo `navegator`) + +El transport está en 4 primitivas Python reutilizables (cualquier automatización de la sesión diaria): + +| ID | Qué hace | +|---|---| +| [cdp_eval_py_browser](../../python/functions/browser/cdp_eval.md) | Evalúa JS en un target por substring de URL (leer DOM, `focus()`, resolver coords). | +| [cdp_type_chars_py_browser](../../python/functions/browser/cdp_type_chars.md) | Escribe char-by-char con key events reales (único método que funciona con el editor Lexical). | +| [cdp_press_key_py_browser](../../python/functions/browser/cdp_press_key.md) | Pulsa una tecla nombrada (Enter, Escape, Backspace, Arrows...) con modificadores. | +| [cdp_click_xy_py_browser](../../python/functions/browser/cdp_click_xy.md) | Click de ratón real en coordenadas (necesario: `element.click()` JS no dispara los handlers de React). | + +## Ejemplo canónico end-to-end + +Requisito: WhatsApp Web abierto y logueado en un Chrome con `--remote-debugging-port=9222` +(en este equipo, el CDP global de chromium ya lo expone). No hace falta foco ni ventana visible. + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from browser.whatsapp_read_chat import whatsapp_read_chat +from browser.whatsapp_send_message import whatsapp_send_message + +# Leer los últimos mensajes de un chat +r = whatsapp_read_chat("NOTAS WASAP", n=5) +for m in r["messages"]: + print(("→" if m["outgoing"] else "←"), m["text"]) + +# Enviar un mensaje (acción con efecto: envía de verdad) +res = whatsapp_send_message("NOTAS WASAP", "hola desde el registry") +print(res) # {"sent": True, "last_row": "hola desde el registry 11:48"} +``` + +## Fronteras y gotchas (leer antes de usar) + +- **Viola los ToS de WhatsApp; riesgo de ban del número.** Probar en un chat propio reduce + molestia a terceros pero no elimina el riesgo de detección por patrón. +- **Envío irreversible**: `whatsapp_send_message` envía de verdad y WhatsApp no permite + des-enviar por esta vía. La función verifica destinatario (`name` exacto en el composer) y + contenido antes de Enter, pero el `name` lo das tú: un nombre ambiguo abre el primer match. +- **Nombre exacto requerido** (`span[title]` exacto). El buscador **no filtra de forma fiable + los contactos NO cargados** en la lista lateral; funciona para chats recientes/visibles. Un + contacto sin chat reciente puede no encontrarse (limitación conocida; mejora futura: scroll). +- **Lexical**: escribir SOLO con `cdp_type_chars` (key events reales). `execCommand`/`el.value` + meten texto fantasma y producen duplicación/intercalado. +- **Abrir chats**: requiere click de ratón real (`cdp_click_xy`); `element.click()` JS no abre. +- **`outgoing`** se infiere de `.message-out` (heurístico) y puede no marcar bien los mensajes + propios en algunos grupos; el `text` siempre es fiable. +- **Solo lee lo renderizado** en el viewport del chat; mensajes muy antiguos requieren scroll + (no implementado). +- Funciona con la ventana **minimizada y sin foco** (CDP no depende del foco del SO). + +## Prerequisitos + +- Chrome/Chromium con remote debugging en el puerto 9222 y WhatsApp Web logueado. +- `websocket-client` en `python/.venv` (ya presente). Sin dependencias nuevas. diff --git a/functions/infra/collect_battery_metrics_test.go b/functions/infra/collect_battery_metrics_test.go new file mode 100644 index 00000000..4abb9942 --- /dev/null +++ b/functions/infra/collect_battery_metrics_test.go @@ -0,0 +1,142 @@ +package infra + +import "testing" + +// findSample devuelve el primer sample con el nombre dado, o nil si no existe. +func findSample(samples []PromSample, name string) *PromSample { + for i := range samples { + if samples[i].Name == name { + return &samples[i] + } + } + return nil +} + +func TestCollectBatteryMetrics_ParseDischarging(t *testing.T) { + in := []byte(`{"health":"GOOD","percentage":85,"plugged":"UNPLUGGED","status":"DISCHARGING","temperature":28.9,"current":-350000}`) + + samples, err := parseBatteryJSON(in) + if err != nil { + t.Fatalf("parseBatteryJSON returned error: %v", err) + } + + t.Run("percent", func(t *testing.T) { + s := findSample(samples, "node_battery_percent") + if s == nil { + t.Fatal("missing node_battery_percent") + } + if s.Value != 85 { + t.Errorf("got percent %v, want 85", s.Value) + } + }) + + t.Run("temp", func(t *testing.T) { + s := findSample(samples, "node_battery_temp_celsius") + if s == nil { + t.Fatal("missing node_battery_temp_celsius") + } + if s.Value != 28.9 { + t.Errorf("got temp %v, want 28.9", s.Value) + } + }) + + t.Run("charging zero when discharging and unplugged", func(t *testing.T) { + s := findSample(samples, "node_battery_charging") + if s == nil { + t.Fatal("missing node_battery_charging") + } + if s.Value != 0 { + t.Errorf("got charging %v, want 0", s.Value) + } + }) + + t.Run("current", func(t *testing.T) { + s := findSample(samples, "node_battery_current_ua") + if s == nil { + t.Fatal("missing node_battery_current_ua") + } + if s.Value != -350000 { + t.Errorf("got current %v, want -350000", s.Value) + } + }) + + t.Run("health info series with labels", func(t *testing.T) { + s := findSample(samples, "node_battery_health_info") + if s == nil { + t.Fatal("missing node_battery_health_info") + } + if s.Value != 1 { + t.Errorf("got health_info value %v, want 1", s.Value) + } + if s.Labels["health"] != "GOOD" { + t.Errorf("got health label %q, want GOOD", s.Labels["health"]) + } + if s.Labels["status"] != "DISCHARGING" { + t.Errorf("got status label %q, want DISCHARGING", s.Labels["status"]) + } + if s.Labels["plugged"] != "UNPLUGGED" { + t.Errorf("got plugged label %q, want UNPLUGGED", s.Labels["plugged"]) + } + }) +} + +func TestCollectBatteryMetrics_ParseCharging(t *testing.T) { + in := []byte(`{"health":"GOOD","percentage":60,"plugged":"PLUGGED_AC","status":"CHARGING","temperature":31.2,"current":420000}`) + + samples, err := parseBatteryJSON(in) + if err != nil { + t.Fatalf("parseBatteryJSON returned error: %v", err) + } + + s := findSample(samples, "node_battery_charging") + if s == nil { + t.Fatal("missing node_battery_charging") + } + if s.Value != 1 { + t.Errorf("got charging %v, want 1 (status CHARGING)", s.Value) + } +} + +func TestCollectBatteryMetrics_ParsePluggedNotUnplugged(t *testing.T) { + // status no es CHARGING/FULL pero plugged != UNPLUGGED -> charging = 1. + in := []byte(`{"health":"GOOD","percentage":100,"plugged":"PLUGGED_USB","status":"NOT_CHARGING","temperature":30.0,"current":0}`) + + samples, err := parseBatteryJSON(in) + if err != nil { + t.Fatalf("parseBatteryJSON returned error: %v", err) + } + + s := findSample(samples, "node_battery_charging") + if s == nil { + t.Fatal("missing node_battery_charging") + } + if s.Value != 1 { + t.Errorf("got charging %v, want 1 (plugged != UNPLUGGED)", s.Value) + } +} + +func TestCollectBatteryMetrics_ParseFull(t *testing.T) { + in := []byte(`{"health":"GOOD","percentage":100,"plugged":"UNPLUGGED","status":"FULL","temperature":29.5,"current":0}`) + + samples, err := parseBatteryJSON(in) + if err != nil { + t.Fatalf("parseBatteryJSON returned error: %v", err) + } + + s := findSample(samples, "node_battery_charging") + if s == nil { + t.Fatal("missing node_battery_charging") + } + if s.Value != 1 { + t.Errorf("got charging %v, want 1 (status FULL)", s.Value) + } +} + +func TestCollectBatteryMetrics_InvalidJSON(t *testing.T) { + in := []byte(`not a json at all`) + + _, err := parseBatteryJSON(in) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} diff --git a/functions/infra/collect_host_metrics_test.go b/functions/infra/collect_host_metrics_test.go new file mode 100644 index 00000000..cb1feb7b --- /dev/null +++ b/functions/infra/collect_host_metrics_test.go @@ -0,0 +1,43 @@ +package infra + +import "testing" + +func TestCollectHostMetrics_ReturnsBasics(t *testing.T) { + samples, err := CollectHostMetrics() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(samples) == 0 { + t.Fatal("expected at least one sample") + } + + // node_uptime_seconds es el unico colector imprescindible: debe estar siempre. + found := false + for _, s := range samples { + if s.Name == "node_uptime_seconds" { + found = true + if s.Value <= 0 { + t.Errorf("node_uptime_seconds should be positive, got %v", s.Value) + } + } + } + if !found { + t.Error("node_uptime_seconds not present in samples") + } +} + +func TestCollectHostMetrics_SamplesWellFormed(t *testing.T) { + samples, err := CollectHostMetrics() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for i, s := range samples { + if s.Name == "" { + t.Errorf("sample %d has empty Name", i) + } + // La label "instance" NO debe estar: la añade el pusher. + if _, ok := s.Labels["instance"]; ok { + t.Errorf("sample %d (%s) must not carry the instance label", i, s.Name) + } + } +} diff --git a/functions/infra/format_prom_exposition_test.go b/functions/infra/format_prom_exposition_test.go new file mode 100644 index 00000000..21d8962d --- /dev/null +++ b/functions/infra/format_prom_exposition_test.go @@ -0,0 +1,74 @@ +package infra + +import "testing" + +func TestFormatPromExposition(t *testing.T) { + t.Run("varias series con y sin labels con timestamp", func(t *testing.T) { + samples := []PromSample{ + {Name: "node_load1", Value: 0.42}, + {Name: "node_cpu_core_percent", Labels: map[string]string{"core": "0"}, Value: 12.5}, + {Name: "node_disk_used_bytes", Labels: map[string]string{"mount": "/"}, Value: 1024}, + } + got := FormatPromExposition(samples, 1700000000000) + want := "node_load1 0.42 1700000000000\n" + + "node_cpu_core_percent{core=\"0\"} 12.5 1700000000000\n" + + "node_disk_used_bytes{mount=\"/\"} 1024 1700000000000\n" + if got != want { + t.Errorf("got:\n%q\nwant:\n%q", got, want) + } + }) + + t.Run("sin timestamp omite el campo", func(t *testing.T) { + samples := []PromSample{ + {Name: "node_load1", Value: 0.42}, + {Name: "node_cpu_percent", Value: 3}, + } + got := FormatPromExposition(samples, 0) + want := "node_load1 0.42\n" + + "node_cpu_percent 3\n" + if got != want { + t.Errorf("got:\n%q\nwant:\n%q", got, want) + } + }) + + t.Run("labels ordenadas por clave deterministico", func(t *testing.T) { + samples := []PromSample{ + {Name: "node_proc_cpu_percent", Labels: map[string]string{"pid": "42", "name": "claude"}, Value: 7.5}, + } + got := FormatPromExposition(samples, 0) + // "name" antes que "pid" alfabeticamente. + want := "node_proc_cpu_percent{name=\"claude\",pid=\"42\"} 7.5\n" + if got != want { + t.Errorf("got:\n%q\nwant:\n%q", got, want) + } + }) + + t.Run("escapa backslash comilla y newline en valor de label", func(t *testing.T) { + samples := []PromSample{ + {Name: "node_proc_cpu_percent", Labels: map[string]string{"name": "a\\b\"c\nd"}, Value: 1}, + } + got := FormatPromExposition(samples, 0) + want := "node_proc_cpu_percent{name=\"a\\\\b\\\"c\\nd\"} 1\n" + if got != want { + t.Errorf("got:\n%q\nwant:\n%q", got, want) + } + }) + + t.Run("sanitiza nombre de metrica invalido", func(t *testing.T) { + samples := []PromSample{ + {Name: "node.cpu-percent!", Value: 5}, + } + got := FormatPromExposition(samples, 0) + want := "node_cpu_percent_ 5\n" + if got != want { + t.Errorf("got:\n%q\nwant:\n%q", got, want) + } + }) + + t.Run("slice vacio produce string vacio", func(t *testing.T) { + got := FormatPromExposition(nil, 1700000000000) + if got != "" { + t.Errorf("got %q, want empty string", got) + } + }) +} diff --git a/functions/infra/push_loki_stream_test.go b/functions/infra/push_loki_stream_test.go new file mode 100644 index 00000000..0744f53b --- /dev/null +++ b/functions/infra/push_loki_stream_test.go @@ -0,0 +1,139 @@ +package infra + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// lokiPushBody refleja la estructura JSON que espera el push API de Loki. +type lokiPushBody struct { + Streams []struct { + Stream map[string]string `json:"stream"` + Values [][2]string `json:"values"` + } `json:"streams"` +} + +func TestPushLokiStream(t *testing.T) { + t.Run("JSON enviado tiene estructura streams/stream/values correcta", func(t *testing.T) { + var captured lokiPushBody + var contentType string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + contentType = r.Header.Get("Content-Type") + raw, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(raw, &captured); err != nil { + t.Errorf("body no es JSON valido: %v", err) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + labels := map[string]string{"instance": "lucas", "job": "journald", "unit": "ssh.service"} + ts := []int64{1700000000000000001, 1700000000000000002} + lines := []string{"line one", "line two"} + + err := PushLokiStream(srv.URL, "", "", labels, ts, lines) + if err != nil { + t.Fatalf("error inesperado: %v", err) + } + + if contentType != "application/json" { + t.Errorf("Content-Type = %q, want application/json", contentType) + } + if len(captured.Streams) != 1 { + t.Fatalf("streams len = %d, want 1", len(captured.Streams)) + } + s := captured.Streams[0] + if s.Stream["unit"] != "ssh.service" || s.Stream["job"] != "journald" || s.Stream["instance"] != "lucas" { + t.Errorf("stream labels = %v, want %v", s.Stream, labels) + } + if len(s.Values) != 2 { + t.Fatalf("values len = %d, want 2", len(s.Values)) + } + if s.Values[0][0] != "1700000000000000001" || s.Values[0][1] != "line one" { + t.Errorf("values[0] = %v, want [1700000000000000001 line one]", s.Values[0]) + } + if s.Values[1][0] != "1700000000000000002" || s.Values[1][1] != "line two" { + t.Errorf("values[1] = %v, want [1700000000000000002 line two]", s.Values[1]) + } + }) + + t.Run("longitudes desiguales dan error antes del POST", func(t *testing.T) { + hit := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hit = true + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + ts := []int64{1, 2, 3} + lines := []string{"only one"} + err := PushLokiStream(srv.URL, "", "", map[string]string{"job": "x"}, ts, lines) + if err == nil { + t.Fatalf("se esperaba error por longitudes desiguales") + } + if hit { + t.Errorf("no debe haber peticion HTTP cuando las longitudes no coinciden") + } + }) + + t.Run("len lines cero es no-op sin peticion", func(t *testing.T) { + hit := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hit = true + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + err := PushLokiStream(srv.URL, "", "", map[string]string{"job": "x"}, []int64{}, []string{}) + if err != nil { + t.Fatalf("no-op no debe retornar error: %v", err) + } + if hit { + t.Errorf("no-op no debe hacer ninguna peticion HTTP") + } + }) + + t.Run("Basic Auth presente cuando user no vacio", func(t *testing.T) { + var gotUser, gotPass string + var hadAuth bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, gotPass, hadAuth = r.BasicAuth() + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + err := PushLokiStream(srv.URL, "tenant", "secret", map[string]string{"job": "x"}, []int64{1}, []string{"hi"}) + if err != nil { + t.Fatalf("error inesperado: %v", err) + } + if !hadAuth { + t.Fatalf("se esperaba header Authorization Basic") + } + if gotUser != "tenant" || gotPass != "secret" { + t.Errorf("basic auth = %q/%q, want tenant/secret", gotUser, gotPass) + } + }) + + t.Run("status 500 produce error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("loki rejected the push")) + })) + defer srv.Close() + + err := PushLokiStream(srv.URL, "", "", map[string]string{"job": "x"}, []int64{1}, []string{"hi"}) + if err == nil { + t.Fatalf("se esperaba error con status 500") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("error no menciona el codigo 500: %v", err) + } + if !strings.Contains(err.Error(), "loki rejected the push") { + t.Errorf("error no incluye el cuerpo de respuesta: %v", err) + } + }) +} diff --git a/functions/infra/push_prom_remote_test.go b/functions/infra/push_prom_remote_test.go new file mode 100644 index 00000000..2772704e --- /dev/null +++ b/functions/infra/push_prom_remote_test.go @@ -0,0 +1,111 @@ +package infra + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPushPromRemote(t *testing.T) { + t.Run("body llega completo y status 204 es exito", func(t *testing.T) { + const body = "node_load1 0.42 1700000000000\nnode_cpu_percent 3\n" + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + if ct := r.Header.Get("Content-Type"); ct != "text/plain" { + t.Errorf("Content-Type = %q, want text/plain", ct) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + if err := PushPromRemote(srv.URL, "", "", body, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotBody != body { + t.Errorf("body got %q, want %q", gotBody, body) + } + }) + + t.Run("basic auth presente cuando user no vacio", func(t *testing.T) { + var hadAuth bool + var gotUser, gotPass string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, gotPass, hadAuth = r.BasicAuth() + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + if err := PushPromRemote(srv.URL, "alice", "s3cr3t", "x 1\n", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !hadAuth { + t.Fatal("expected Authorization Basic header, got none") + } + if gotUser != "alice" || gotPass != "s3cr3t" { + t.Errorf("basic auth = %q/%q, want alice/s3cr3t", gotUser, gotPass) + } + }) + + t.Run("sin user no manda Authorization", func(t *testing.T) { + var hadAuth bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _, hadAuth = r.BasicAuth() + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + if err := PushPromRemote(srv.URL, "", "ignored", "x 1\n", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hadAuth { + t.Error("expected no Authorization header when user is empty") + } + }) + + t.Run("extra_label aparece en la query", func(t *testing.T) { + var gotQuery []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query()["extra_label"] + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + labels := map[string]string{"instance": "lucas", "region": "eu"} + if err := PushPromRemote(srv.URL, "", "", "x 1\n", labels); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(gotQuery) != 2 { + t.Fatalf("got %d extra_label params, want 2: %v", len(gotQuery), gotQuery) + } + joined := strings.Join(gotQuery, ",") + if !strings.Contains(joined, "instance=lucas") { + t.Errorf("extra_label missing instance=lucas: %v", gotQuery) + } + if !strings.Contains(joined, "region=eu") { + t.Errorf("extra_label missing region=eu: %v", gotQuery) + } + }) + + t.Run("status 500 produce error con codigo y snippet", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + io.WriteString(w, "boom: bad input") + })) + defer srv.Close() + + err := PushPromRemote(srv.URL, "", "", "x 1\n", nil) + if err == nil { + t.Fatal("expected error on status 500, got nil") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("error should mention status 500: %v", err) + } + if !strings.Contains(err.Error(), "boom") { + t.Errorf("error should include response body snippet: %v", err) + } + }) +} diff --git a/python/functions/browser/whatsapp_open_chat.md b/python/functions/browser/whatsapp_open_chat.md new file mode 100644 index 00000000..e18a6191 --- /dev/null +++ b/python/functions/browser/whatsapp_open_chat.md @@ -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. diff --git a/python/functions/browser/whatsapp_open_chat.py b/python/functions/browser/whatsapp_open_chat.py new file mode 100644 index 00000000..85faaff4 --- /dev/null +++ b/python/functions/browser/whatsapp_open_chat.py @@ -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=""]` 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)) diff --git a/python/functions/browser/whatsapp_open_chat_test.py b/python/functions/browser/whatsapp_open_chat_test.py new file mode 100644 index 00000000..130ca539 --- /dev/null +++ b/python/functions/browser/whatsapp_open_chat_test.py @@ -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 diff --git a/python/functions/browser/whatsapp_read_chat.md b/python/functions/browser/whatsapp_read_chat.md new file mode 100644 index 00000000..a06e514f --- /dev/null +++ b/python/functions/browser/whatsapp_read_chat.md @@ -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. diff --git a/python/functions/browser/whatsapp_read_chat.py b/python/functions/browser/whatsapp_read_chat.py new file mode 100644 index 00000000..c4241a18 --- /dev/null +++ b/python/functions/browser/whatsapp_read_chat.py @@ -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)) diff --git a/python/functions/browser/whatsapp_read_chat_test.py b/python/functions/browser/whatsapp_read_chat_test.py new file mode 100644 index 00000000..cbb2df79 --- /dev/null +++ b/python/functions/browser/whatsapp_read_chat_test.py @@ -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 diff --git a/python/functions/browser/whatsapp_send_message.md b/python/functions/browser/whatsapp_send_message.md new file mode 100644 index 00000000..e5207190 --- /dev/null +++ b/python/functions/browser/whatsapp_send_message.md @@ -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`). diff --git a/python/functions/browser/whatsapp_send_message.py b/python/functions/browser/whatsapp_send_message.py new file mode 100644 index 00000000..f38594fb --- /dev/null +++ b/python/functions/browser/whatsapp_send_message.py @@ -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)) diff --git a/python/functions/browser/whatsapp_send_message_test.py b/python/functions/browser/whatsapp_send_message_test.py new file mode 100644 index 00000000..da37c34f --- /dev/null +++ b/python/functions/browser/whatsapp_send_message_test.py @@ -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