Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10bfb846a8 | |||
| d996542f88 |
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,120 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// batteryStatus modela el JSON que imprime `termux-battery-status` (binario del
|
||||
// paquete termux-api en Android/Termux). Solo se declaran los campos que
|
||||
// consumimos como metricas.
|
||||
type batteryStatus struct {
|
||||
Health string `json:"health"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
Plugged string `json:"plugged"`
|
||||
Status string `json:"status"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
Current float64 `json:"current"`
|
||||
}
|
||||
|
||||
// CollectBatteryMetrics recolecta metricas de bateria de un dispositivo
|
||||
// Android via el comando `termux-battery-status` (paquete termux-api) y las
|
||||
// devuelve como slice de PromSample con nombres estilo node_exporter.
|
||||
//
|
||||
// Es best-effort y diseñada para correr en cualquier nodo de la flota,
|
||||
// incluidos Linux normales donde `termux-battery-status` NO existe: en ese
|
||||
// caso (binario no encontrado, comando fallido o JSON invalido) devuelve un
|
||||
// slice vacio y error nil — NO es un fallo, simplemente no hay bateria que
|
||||
// reportar. Solo emite samples cuando el comando existe y responde JSON valido.
|
||||
//
|
||||
// El comando se ejecuta con un timeout de 5s via context para no colgar el
|
||||
// agente de monitorizacion si termux-api se queda sin responder.
|
||||
func CollectBatteryMetrics() ([]PromSample, error) {
|
||||
// Localizamos el binario por ruta absoluta con os.Stat en vez de
|
||||
// exec.LookPath: en Android el syscall faccessat2 que usa LookPath esta
|
||||
// bloqueado por seccomp y mata el proceso con SIGSYS. Si no esta presente
|
||||
// (Linux normal), no hay bateria que reportar (no-op).
|
||||
bin := findTermuxBattery()
|
||||
if bin == "" {
|
||||
return []PromSample{}, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, bin).Output()
|
||||
if err != nil {
|
||||
// Comando presente pero fallido (timeout, sin permisos, etc.): no-op.
|
||||
return []PromSample{}, nil
|
||||
}
|
||||
|
||||
samples, err := parseBatteryJSON(out)
|
||||
if err != nil {
|
||||
// JSON inesperado o invalido: no-op, no abortamos al caller.
|
||||
return []PromSample{}, nil
|
||||
}
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
// findTermuxBattery devuelve la ruta absoluta del binario termux-battery-status
|
||||
// si existe, o "" si no. Usa os.Stat (permitido por seccomp en Android) en vez
|
||||
// de exec.LookPath (que invoca faccessat2 y crashea con SIGSYS en Android).
|
||||
func findTermuxBattery() string {
|
||||
candidates := []string{}
|
||||
if prefix := os.Getenv("PREFIX"); prefix != "" {
|
||||
candidates = append(candidates, prefix+"/bin/termux-battery-status")
|
||||
}
|
||||
candidates = append(candidates, "/data/data/com.termux/files/usr/bin/termux-battery-status")
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BatterySamplesFromJSON parsea la salida JSON de `termux-battery-status` y
|
||||
// produce los PromSample de bateria. Es pura y exportada para que un caller que
|
||||
// ya tenga el JSON (por ejemplo leido de un fichero, util en Android donde el
|
||||
// agente no puede ejecutar subprocesos) lo convierta sin volver a ejecutar el
|
||||
// comando.
|
||||
func BatterySamplesFromJSON(data []byte) ([]PromSample, error) {
|
||||
return parseBatteryJSON(data)
|
||||
}
|
||||
|
||||
// parseBatteryJSON parsea la salida JSON de `termux-battery-status` y produce
|
||||
// los PromSample de bateria. Es pura: no ejecuta comandos ni toca el entorno,
|
||||
// lo que la hace testeable con un JSON fijo. Devuelve error solo si el JSON no
|
||||
// es valido o no tiene la forma esperada.
|
||||
func parseBatteryJSON(data []byte) ([]PromSample, error) {
|
||||
var bs batteryStatus
|
||||
if err := json.Unmarshal(data, &bs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// charging = 1 si el status indica carga/lleno o si esta enchufado.
|
||||
var charging float64
|
||||
if bs.Status == "CHARGING" || bs.Status == "FULL" || bs.Plugged != "UNPLUGGED" {
|
||||
charging = 1
|
||||
}
|
||||
|
||||
samples := []PromSample{
|
||||
{Name: "node_battery_percent", Value: bs.Percentage},
|
||||
{Name: "node_battery_temp_celsius", Value: bs.Temperature},
|
||||
{Name: "node_battery_charging", Value: charging},
|
||||
{Name: "node_battery_current_ua", Value: bs.Current},
|
||||
{
|
||||
Name: "node_battery_health_info",
|
||||
Labels: map[string]string{
|
||||
"health": bs.Health,
|
||||
"status": bs.Status,
|
||||
"plugged": bs.Plugged,
|
||||
},
|
||||
Value: 1,
|
||||
},
|
||||
}
|
||||
return samples, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: collect_battery_metrics
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CollectBatteryMetrics() ([]PromSample, error)"
|
||||
description: "Recolecta metricas de bateria de un dispositivo Android via el comando termux-battery-status (paquete termux-api) y las devuelve como slice de PromSample con nombres estilo node_exporter: porcentaje, temperatura, estado de carga (booleano), corriente en microamperios y una serie informativa node_battery_health_info con labels health/status/plugged. Best-effort y multiplataforma: en nodos sin termux-battery-status (Linux normales) es un no-op que devuelve slice vacio y error nil; solo emite samples cuando el comando existe y responde JSON valido. El comando corre con timeout de 5s via context."
|
||||
tags: [prometheus, metrics, node-exporter, battery, termux, android, fleet-metrics, infra, monitoring]
|
||||
uses_functions: []
|
||||
uses_types: ["PromSample_go_infra"]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["context", "encoding/json", "os/exec", "time"]
|
||||
params: []
|
||||
output: "slice de PromSample con metricas de bateria. node_battery_percent (0-100), node_battery_temp_celsius, node_battery_charging (1 si carga/lleno/enchufado, si no 0), node_battery_current_ua (microamperios, negativo al descargar) y node_battery_health_info{health,status,plugged} con value 1. En nodos sin termux-api devuelve slice vacio. Error nil siempre en condiciones normales: la funcion traga los fallos de ejecucion/parseo como no-op (slice vacio)."
|
||||
tested: true
|
||||
tests:
|
||||
- "TestCollectBatteryMetrics_ParseDischarging"
|
||||
- "TestCollectBatteryMetrics_ParseCharging"
|
||||
- "TestCollectBatteryMetrics_ParsePluggedNotUnplugged"
|
||||
- "TestCollectBatteryMetrics_ParseFull"
|
||||
- "TestCollectBatteryMetrics_InvalidJSON"
|
||||
test_file_path: "functions/infra/collect_battery_metrics_test.go"
|
||||
file_path: "functions/infra/collect_battery_metrics.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
samples, err := CollectBatteryMetrics()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// En un Linux normal samples sera vacio (no-op); en Android/Termux trae
|
||||
// node_battery_percent, node_battery_temp_celsius, node_battery_charging, etc.
|
||||
// Componer con el resto del capability group fleet-metrics:
|
||||
host, _ := CollectHostMetrics()
|
||||
all := append(host, samples...)
|
||||
body := FormatPromExposition(all, time.Now().UnixMilli())
|
||||
err = PushPromRemote(
|
||||
"https://metrics-xxxx.organic-machine.com/api/v1/import/prometheus",
|
||||
"user", "pass",
|
||||
body,
|
||||
map[string]string{"instance": "pixel-phone"},
|
||||
)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando un nodo de la flota es un movil Android con Termux + termux-api y quieres
|
||||
exponer la salud de su bateria como metricas Prometheus para push a un backend
|
||||
remoto (VictoriaMetrics, Mimir). Llamala junto a `collect_host_metrics_go_infra`
|
||||
en el loop del agente de monitorizacion push y concatena los slices: en moviles
|
||||
añade las series de bateria, en el resto de nodos no aporta nada (no-op seguro),
|
||||
asi puedes usar el MISMO agente en toda la flota sin ramas por plataforma.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Solo produce datos en Termux/Android con termux-api instalado**: necesita el
|
||||
binario `termux-battery-status` (paquete `termux-api` + la app Termux:API). En
|
||||
cualquier otro nodo (Linux de escritorio, VPS, macOS) `exec.LookPath` falla y
|
||||
la funcion es un no-op que devuelve `[]PromSample{}, nil`. No es un error:
|
||||
simplemente no hay bateria que reportar.
|
||||
- **No devuelve error nunca en condiciones normales**: por diseño best-effort,
|
||||
tanto el binario ausente como un comando fallido (timeout, permisos) o un JSON
|
||||
invalido se tragan como slice vacio. La firma mantiene `error` por convencion
|
||||
de impureza, pero el caller no necesita ramificar por error de plataforma.
|
||||
- **Timeout de 5s**: usa `exec.CommandContext` con `context.WithTimeout`. Si
|
||||
termux-api se cuelga, la llamada aborta a los 5s y devuelve no-op.
|
||||
- **node_battery_current_ua puede ser negativo**: convencion de Android — corriente
|
||||
negativa = descarga, positiva = carga. Se reporta tal cual (microamperios).
|
||||
- **node_battery_charging es heuristico**: vale 1 si `status` es `CHARGING` o
|
||||
`FULL`, o si `plugged != "UNPLUGGED"`. Cubre el caso de estar enchufado sin
|
||||
cargar activamente (ej. `NOT_CHARGING` con cargador conectado).
|
||||
- **No incluye la label `instance`**: igual que el resto de colectores del grupo,
|
||||
esa la añade `push_prom_remote_go_infra` via extra_label en el push.
|
||||
- **El parseo esta factorizado** en `parseBatteryJSON` (funcion pura interna) para
|
||||
poder testear los samples sin ejecutar termux-battery-status real.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/cpu"
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
"github.com/shirou/gopsutil/v4/host"
|
||||
"github.com/shirou/gopsutil/v4/load"
|
||||
"github.com/shirou/gopsutil/v4/mem"
|
||||
"github.com/shirou/gopsutil/v4/net"
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
"github.com/shirou/gopsutil/v4/sensors"
|
||||
)
|
||||
|
||||
// isAndroidHost indica si el host es Android (incluido Termux). Se usa para
|
||||
// evitar rutas de gopsutil que invocan os.FindProcess -> pidfd_open, syscall
|
||||
// bloqueado por el seccomp de Android que mata el proceso con SIGSYS.
|
||||
func isAndroidHost() bool {
|
||||
if os.Getenv("ANDROID_ROOT") != "" || os.Getenv("ANDROID_DATA") != "" {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat("/system/build.prop"); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pseudoFstypes son filesystems virtuales que no representan almacenamiento
|
||||
// real y se ignoran al recolectar metricas de particiones.
|
||||
var pseudoFstypes = map[string]bool{
|
||||
"tmpfs": true,
|
||||
"devtmpfs": true,
|
||||
"overlay": true,
|
||||
"squashfs": true,
|
||||
"proc": true,
|
||||
"sysfs": true,
|
||||
"cgroup": true,
|
||||
"cgroup2": true,
|
||||
"devpts": true,
|
||||
"mqueue": true,
|
||||
"debugfs": true,
|
||||
"tracefs": true,
|
||||
"fusectl": true,
|
||||
"configfs": true,
|
||||
"pstore": true,
|
||||
"bpf": true,
|
||||
"securityfs": true,
|
||||
}
|
||||
|
||||
// CollectHostMetrics recolecta metricas del host actual (CPU, memoria, swap,
|
||||
// disco, red, temperaturas y procesos) y las devuelve como un slice de
|
||||
// PromSample con nombres estilo node_exporter simplificados.
|
||||
//
|
||||
// Es robusta: cada grupo de colector se ejecuta en su propio bloque con manejo
|
||||
// de error local. Si un colector secundario falla (red, temperaturas, etc.) se
|
||||
// omite ese grupo sin abortar. Solo retorna error si falla la informacion
|
||||
// basica de host (uptime), que se considera el minimo imprescindible.
|
||||
//
|
||||
// Funciona en Linux amd64 y Android/Termux (linux arm64): las temperaturas son
|
||||
// best-effort y se omiten si no hay sensores disponibles (tipico en Android).
|
||||
func CollectHostMetrics() ([]PromSample, error) {
|
||||
var samples []PromSample
|
||||
|
||||
// --- Host basico: uptime (imprescindible, error si falla) ---
|
||||
uptime, err := host.Uptime()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collect host uptime: %w", err)
|
||||
}
|
||||
samples = append(samples, PromSample{
|
||||
Name: "node_uptime_seconds",
|
||||
Value: float64(uptime),
|
||||
})
|
||||
|
||||
// --- Load average (linux/darwin; best-effort) ---
|
||||
if avg, err := load.Avg(); err == nil && avg != nil {
|
||||
samples = append(samples,
|
||||
PromSample{Name: "node_load1", Value: avg.Load1},
|
||||
PromSample{Name: "node_load5", Value: avg.Load5},
|
||||
PromSample{Name: "node_load15", Value: avg.Load15},
|
||||
)
|
||||
}
|
||||
|
||||
// --- CPU global (intervalo corto de muestreo) ---
|
||||
if pcts, err := cpu.Percent(200*time.Millisecond, false); err == nil && len(pcts) > 0 {
|
||||
samples = append(samples, PromSample{
|
||||
Name: "node_cpu_percent",
|
||||
Value: pcts[0],
|
||||
})
|
||||
}
|
||||
|
||||
// --- CPU por nucleo ---
|
||||
if pcts, err := cpu.Percent(200*time.Millisecond, true); err == nil {
|
||||
for i, p := range pcts {
|
||||
samples = append(samples, PromSample{
|
||||
Name: "node_cpu_core_percent",
|
||||
Labels: map[string]string{"core": strconv.Itoa(i)},
|
||||
Value: p,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Memoria virtual ---
|
||||
if vm, err := mem.VirtualMemory(); err == nil && vm != nil {
|
||||
samples = append(samples,
|
||||
PromSample{Name: "node_mem_total_bytes", Value: float64(vm.Total)},
|
||||
PromSample{Name: "node_mem_used_bytes", Value: float64(vm.Used)},
|
||||
PromSample{Name: "node_mem_available_bytes", Value: float64(vm.Available)},
|
||||
PromSample{Name: "node_mem_used_percent", Value: vm.UsedPercent},
|
||||
)
|
||||
}
|
||||
|
||||
// --- Swap ---
|
||||
if sw, err := mem.SwapMemory(); err == nil && sw != nil {
|
||||
samples = append(samples,
|
||||
PromSample{Name: "node_swap_total_bytes", Value: float64(sw.Total)},
|
||||
PromSample{Name: "node_swap_used_bytes", Value: float64(sw.Used)},
|
||||
)
|
||||
}
|
||||
|
||||
// --- Particiones fisicas (ignora fstypes pseudo) ---
|
||||
if parts, err := disk.Partitions(false); err == nil {
|
||||
for _, p := range parts {
|
||||
if pseudoFstypes[p.Fstype] {
|
||||
continue
|
||||
}
|
||||
u, err := disk.Usage(p.Mountpoint)
|
||||
if err != nil || u == nil {
|
||||
continue
|
||||
}
|
||||
lbl := map[string]string{"mount": p.Mountpoint}
|
||||
samples = append(samples,
|
||||
PromSample{Name: "node_disk_total_bytes", Labels: lbl, Value: float64(u.Total)},
|
||||
PromSample{Name: "node_disk_used_bytes", Labels: lbl, Value: float64(u.Used)},
|
||||
PromSample{Name: "node_disk_used_percent", Labels: lbl, Value: u.UsedPercent},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Contadores I/O por dispositivo ---
|
||||
if io, err := disk.IOCounters(); err == nil {
|
||||
for dev, c := range io {
|
||||
lbl := map[string]string{"device": dev}
|
||||
samples = append(samples,
|
||||
PromSample{Name: "node_disk_read_bytes", Labels: lbl, Value: float64(c.ReadBytes)},
|
||||
PromSample{Name: "node_disk_write_bytes", Labels: lbl, Value: float64(c.WriteBytes)},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Red por interfaz (excluye loopback "lo") ---
|
||||
if nics, err := net.IOCounters(true); err == nil {
|
||||
for _, n := range nics {
|
||||
if n.Name == "lo" {
|
||||
continue
|
||||
}
|
||||
lbl := map[string]string{"iface": n.Name}
|
||||
samples = append(samples,
|
||||
PromSample{Name: "node_net_recv_bytes", Labels: lbl, Value: float64(n.BytesRecv)},
|
||||
PromSample{Name: "node_net_sent_bytes", Labels: lbl, Value: float64(n.BytesSent)},
|
||||
PromSample{Name: "node_net_recv_errs", Labels: lbl, Value: float64(n.Errin)},
|
||||
PromSample{Name: "node_net_sent_errs", Labels: lbl, Value: float64(n.Errout)},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Temperaturas (best-effort; omite el grupo si falla o no hay sensores) ---
|
||||
if temps, err := sensors.SensorsTemperatures(); err == nil {
|
||||
for _, t := range temps {
|
||||
if t.SensorKey == "" {
|
||||
continue
|
||||
}
|
||||
samples = append(samples, PromSample{
|
||||
Name: "node_temp_celsius",
|
||||
Labels: map[string]string{"sensor": t.SensorKey},
|
||||
Value: t.Temperature,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Procesos: total + top 5 por CPU ---
|
||||
// En Android (Termux) gopsutil process.Processes() llama internamente a
|
||||
// os.FindProcess, que usa el syscall pidfd_open bloqueado por el seccomp de
|
||||
// Android (mata el proceso con SIGSYS, no recuperable). Alli contamos los
|
||||
// PIDs con process.Pids() (que solo lee /proc, sin FindProcess) y omitimos
|
||||
// el top por CPU.
|
||||
if isAndroidHost() {
|
||||
if pids, err := process.Pids(); err == nil {
|
||||
samples = append(samples, PromSample{
|
||||
Name: "node_procs_total",
|
||||
Value: float64(len(pids)),
|
||||
})
|
||||
}
|
||||
} else if procs, err := process.Processes(); err == nil {
|
||||
samples = append(samples, PromSample{
|
||||
Name: "node_procs_total",
|
||||
Value: float64(len(procs)),
|
||||
})
|
||||
|
||||
type procStat struct {
|
||||
pid int32
|
||||
name string
|
||||
cpu float64
|
||||
mem float32
|
||||
}
|
||||
stats := make([]procStat, 0, len(procs))
|
||||
for _, p := range procs {
|
||||
cpuPct, err := p.CPUPercent()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
name, err := p.Name()
|
||||
if err != nil {
|
||||
name = ""
|
||||
}
|
||||
memPct, err := p.MemoryPercent()
|
||||
if err != nil {
|
||||
memPct = 0
|
||||
}
|
||||
stats = append(stats, procStat{pid: p.Pid, name: name, cpu: cpuPct, mem: memPct})
|
||||
}
|
||||
sort.Slice(stats, func(i, j int) bool {
|
||||
return stats[i].cpu > stats[j].cpu
|
||||
})
|
||||
top := stats
|
||||
if len(top) > 5 {
|
||||
top = top[:5]
|
||||
}
|
||||
for _, s := range top {
|
||||
lbl := map[string]string{
|
||||
"pid": strconv.Itoa(int(s.pid)),
|
||||
"name": s.name,
|
||||
}
|
||||
samples = append(samples,
|
||||
PromSample{Name: "node_proc_cpu_percent", Labels: lbl, Value: s.cpu},
|
||||
PromSample{Name: "node_proc_mem_percent", Labels: lbl, Value: float64(s.mem)},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return samples, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: collect_host_metrics
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CollectHostMetrics() ([]PromSample, error)"
|
||||
description: "Recolecta metricas del host actual (uptime, load, CPU global y por nucleo, memoria, swap, disco por particion fisica e I/O por dispositivo, red por interfaz, temperaturas best-effort y procesos: total + top 5 por CPU) y las devuelve como slice de PromSample con nombres estilo node_exporter simplificados. Robusta: cada grupo de colector tiene manejo de error local; si un colector secundario falla se omite ese grupo sin abortar. Funciona en Linux amd64 y Android/Termux (linux arm64)."
|
||||
tags: [prometheus, metrics, node-exporter, gopsutil, fleet-metrics, infra, monitoring, host]
|
||||
uses_functions: []
|
||||
uses_types: ["PromSample_go_infra"]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["fmt", "sort", "strconv", "time", "github.com/shirou/gopsutil/v4/cpu", "github.com/shirou/gopsutil/v4/disk", "github.com/shirou/gopsutil/v4/host", "github.com/shirou/gopsutil/v4/load", "github.com/shirou/gopsutil/v4/mem", "github.com/shirou/gopsutil/v4/net", "github.com/shirou/gopsutil/v4/process", "github.com/shirou/gopsutil/v4/sensors"]
|
||||
params: []
|
||||
output: "slice de PromSample con las metricas del host. Cada sample lleva nombre estilo node_exporter (node_cpu_percent, node_disk_used_bytes{mount}, etc.) y sus labels. Error solo si falla el uptime de host (informacion basica imprescindible)."
|
||||
tested: true
|
||||
tests:
|
||||
- "TestCollectHostMetrics_ReturnsBasics"
|
||||
- "TestCollectHostMetrics_SamplesWellFormed"
|
||||
test_file_path: "functions/infra/collect_host_metrics_test.go"
|
||||
file_path: "functions/infra/collect_host_metrics.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
samples, err := CollectHostMetrics()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// Formatear a exposition Prometheus y enviar a VictoriaMetrics:
|
||||
body := FormatPromExposition(samples, time.Now().UnixMilli())
|
||||
err = PushPromRemote(
|
||||
"https://metrics-xxxx.organic-machine.com/api/v1/import/prometheus",
|
||||
"user", "pass",
|
||||
body,
|
||||
map[string]string{"instance": "lucas-pc"},
|
||||
)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un snapshot completo de salud del host en formato Prometheus
|
||||
para hacer push a un backend remoto (VictoriaMetrics, Mimir, etc.) en lugar de
|
||||
exponer un endpoint /metrics para scraping. Es el colector base del capability
|
||||
group `fleet-metrics`: combinala con `format_prom_exposition_go_infra` y
|
||||
`push_prom_remote_go_infra` para un agente de monitorizacion push estilo
|
||||
node_exporter. Llamala periodicamente (cron, timer, loop) en cada nodo de la
|
||||
flota.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Bloquea ~400ms**: hace dos llamadas a `cpu.Percent` con intervalo de 200ms
|
||||
cada una (global + por nucleo). No la llames en hot paths ni con periodo < 1s.
|
||||
- **Temperaturas best-effort**: usa `sensors.SensorsTemperatures` (movido del
|
||||
paquete `host` al paquete `sensors` en gopsutil v4). Si no hay sensores
|
||||
(tipico en Android/Termux y muchos VPS) el grupo `node_temp_celsius` se omite
|
||||
sin error.
|
||||
- **Particiones pseudo ignoradas**: tmpfs, devtmpfs, overlay, squashfs, proc,
|
||||
sysfs y similares se filtran. Solo reporta particiones de almacenamiento real.
|
||||
- **Loopback excluido**: la interfaz `lo` no genera metricas de red.
|
||||
- **CPU por proceso necesita dos lecturas**: `CPUPercent()` de gopsutil sobre un
|
||||
proceso recien obtenido puede devolver un valor calculado desde el arranque
|
||||
del proceso, no un delta. Util para ranking relativo del top 5, no como medida
|
||||
instantanea precisa.
|
||||
- **No incluye la label `instance`**: los samples no llevan instance; esa la
|
||||
añade `push_prom_remote_go_infra` via extra_label en el push.
|
||||
- **Permisos**: algunos contadores (procesos de otros usuarios, ciertos sensores)
|
||||
pueden requerir privilegios; los fallos parciales se omiten silenciosamente.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FormatPromExposition convierte un slice de PromSample en texto con formato
|
||||
// Prometheus exposition. Genera una linea por sample:
|
||||
//
|
||||
// name{k1="v1",k2="v2"} value timestampMs
|
||||
//
|
||||
// Reglas:
|
||||
// - Si timestampMs <= 0, omite el campo timestamp.
|
||||
// - Sin labels: "name value" (sin llaves).
|
||||
// - Las labels se ordenan por clave (salida determinista).
|
||||
// - En los valores de label se escapa: backslash -> \\, comilla -> \", newline -> \n.
|
||||
// - El nombre de metrica se sanitiza a [a-zA-Z0-9_:] (el resto -> _).
|
||||
// - El valor se formatea con strconv.FormatFloat(v, 'g', -1, 64).
|
||||
//
|
||||
// Es una funcion pura: no tiene efectos secundarios y la salida es deterministica
|
||||
// para una entrada dada.
|
||||
func FormatPromExposition(samples []PromSample, timestampMs int64) string {
|
||||
var b strings.Builder
|
||||
for _, s := range samples {
|
||||
b.WriteString(sanitizeMetricName(s.Name))
|
||||
|
||||
if len(s.Labels) > 0 {
|
||||
keys := make([]string, 0, len(s.Labels))
|
||||
for k := range s.Labels {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
b.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteString(k)
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(escapeLabelValue(s.Labels[k]))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
b.WriteByte('}')
|
||||
}
|
||||
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(strconv.FormatFloat(s.Value, 'g', -1, 64))
|
||||
|
||||
if timestampMs > 0 {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(strconv.FormatInt(timestampMs, 10))
|
||||
}
|
||||
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// sanitizeMetricName sustituye cualquier caracter fuera de [a-zA-Z0-9_:] por '_'.
|
||||
func sanitizeMetricName(name string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(name))
|
||||
for _, r := range name {
|
||||
if (r >= 'a' && r <= 'z') ||
|
||||
(r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') ||
|
||||
r == '_' || r == ':' {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// escapeLabelValue escapa los caracteres especiales del formato exposition en
|
||||
// el valor de una label: backslash, comilla doble y newline.
|
||||
func escapeLabelValue(v string) string {
|
||||
v = strings.ReplaceAll(v, `\`, `\\`)
|
||||
v = strings.ReplaceAll(v, `"`, `\"`)
|
||||
v = strings.ReplaceAll(v, "\n", `\n`)
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: format_prom_exposition
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func FormatPromExposition(samples []PromSample, timestampMs int64) string"
|
||||
description: "Convierte un slice de PromSample en texto con formato Prometheus exposition (una linea por sample: name{k=\"v\"} value timestampMs). Ordena labels por clave (salida determinista), escapa backslash/comilla/newline en valores de label, sanitiza el nombre de metrica a [a-zA-Z0-9_:], formatea el valor con FormatFloat 'g'. Si timestampMs<=0 omite el timestamp; sin labels omite las llaves. Funcion pura."
|
||||
tags: [prometheus, exposition, metrics, format, fleet-metrics, infra, monitoring]
|
||||
uses_functions: []
|
||||
uses_types: ["PromSample_go_infra"]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["sort", "strconv", "strings"]
|
||||
params:
|
||||
- name: samples
|
||||
desc: "slice de PromSample a serializar; cada uno aporta una linea de exposition"
|
||||
- name: timestampMs
|
||||
desc: "timestamp en milisegundos epoch a adjuntar a cada linea; si es <=0 se omite el campo timestamp"
|
||||
output: "string con el texto exposition Prometheus, una linea por sample terminada en \\n. String vacio si samples esta vacio."
|
||||
tested: true
|
||||
tests:
|
||||
- "TestFormatPromExposition"
|
||||
test_file_path: "functions/infra/format_prom_exposition_test.go"
|
||||
file_path: "functions/infra/format_prom_exposition.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
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},
|
||||
}
|
||||
text := FormatPromExposition(samples, 1700000000000)
|
||||
// node_load1 0.42 1700000000000
|
||||
// node_cpu_core_percent{core="0"} 12.5 1700000000000
|
||||
// node_disk_used_bytes{mount="/"} 1024 1700000000000
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando tengas un slice de PromSample (tipicamente de collect_host_metrics) y
|
||||
necesites serializarlo al formato de texto que entienden los endpoints de
|
||||
ingestion Prometheus (`/api/v1/import/prometheus` de VictoriaMetrics, pushgateway,
|
||||
etc.). Es el paso intermedio del capability group `fleet-metrics`: colecta ->
|
||||
formatea -> empuja. Al ser pura y determinista, tambien sirve para snapshots
|
||||
reproducibles y golden tests.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El timestamp es **milisegundos** epoch (Prometheus exposition usa ms), no
|
||||
segundos. Pasa `time.Now().UnixMilli()`.
|
||||
- `timestampMs <= 0` (incluido 0) omite el campo timestamp por completo.
|
||||
- La label `instance` NO se gestiona aqui: si esta en `Labels` se serializa tal
|
||||
cual, pero la convencion del grupo es dejarla fuera y añadirla en el push via
|
||||
extra_label.
|
||||
- No agrupa por nombre ni emite lineas `# HELP` / `# TYPE`: salida cruda de
|
||||
series, suficiente para ingestion pero no para un endpoint /metrics canonico.
|
||||
- El nombre de metrica se sanitiza de forma destructiva: `node.cpu-percent!` se
|
||||
convierte en `node_cpu_percent_`. Nombra bien los samples en origen.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package infra
|
||||
|
||||
// PromSample representa una unica serie de metrica en formato Prometheus:
|
||||
// el nombre de la metrica, sus labels y un valor numerico.
|
||||
//
|
||||
// La label "instance" NO se incluye aqui: la añade el pusher remoto via
|
||||
// extra_label cuando hace el push a VictoriaMetrics.
|
||||
type PromSample struct {
|
||||
Name string // nombre de metrica prometheus, ej "node_cpu_percent"
|
||||
Labels map[string]string // labels de la serie, ej {"core":"0"} (sin la label "instance")
|
||||
Value float64
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PushLokiStream envia lineas de log a un servidor Grafana Loki via su push API.
|
||||
// Construye el cuerpo JSON con la forma {"streams":[{"stream":{labels},"values":[["<ts_ns>","<line>"],...]}]}
|
||||
// y lo manda por POST a endpoint (ej "https://logs-xxxx.organic-machine.com/loki/api/v1/push").
|
||||
//
|
||||
// Reglas:
|
||||
// - timestampsNs y lines deben tener la misma longitud; si no, retorna error antes de hacer la peticion.
|
||||
// - Si len(lines)==0 es un no-op: no hace ninguna peticion y retorna nil.
|
||||
// - labels va tal cual en el campo "stream".
|
||||
// - Si user != "", usa Basic Auth con user/pass.
|
||||
// - Content-Type: application/json. TLS verificado. Timeout 10s.
|
||||
// - Exito = status 2xx (Loki devuelve 204). Si no-2xx, error con el codigo + primeros 200 bytes del cuerpo.
|
||||
func PushLokiStream(endpoint string, user string, pass string, labels map[string]string, timestampsNs []int64, lines []string) error {
|
||||
if len(timestampsNs) != len(lines) {
|
||||
return fmt.Errorf("push_loki_stream: timestampsNs (%d) y lines (%d) tienen longitudes distintas", len(timestampsNs), len(lines))
|
||||
}
|
||||
|
||||
// No-op cuando no hay lineas.
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
values := make([][2]string, len(lines))
|
||||
for i := range lines {
|
||||
values[i] = [2]string{strconv.FormatInt(timestampsNs[i], 10), lines[i]}
|
||||
}
|
||||
|
||||
stream := labels
|
||||
if stream == nil {
|
||||
stream = map[string]string{}
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"streams": []map[string]any{
|
||||
{
|
||||
"stream": stream,
|
||||
"values": values,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push_loki_stream: marshal body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("push_loki_stream: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push_loki_stream: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
|
||||
return fmt.Errorf("push_loki_stream: HTTP %d: %s", resp.StatusCode, string(snippet))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: push_loki_stream
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func PushLokiStream(endpoint string, user string, pass string, labels map[string]string, timestampsNs []int64, lines []string) error"
|
||||
description: "Envia lineas de log a un servidor Grafana Loki via su push API. Construye el cuerpo JSON {\"streams\":[{\"stream\":{labels},\"values\":[[\"<ts_ns>\",\"<line>\"],...]}]} y lo POSTea al endpoint. Soporta Basic Auth opcional, valida que timestamps y lineas tengan igual longitud, es no-op si no hay lineas, y exige status 2xx. Solo stdlib, TLS verificado."
|
||||
tags: [loki, grafana, logs, push, metrics, http, json, stdlib, infra, fleet-metrics]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["bytes", "encoding/json", "fmt", "io", "net/http", "strconv", "time"]
|
||||
params:
|
||||
- name: endpoint
|
||||
desc: "URL completa del push API de Loki (ej https://logs-xxxx.organic-machine.com/loki/api/v1/push)"
|
||||
- name: user
|
||||
desc: "usuario para Basic Auth; si es cadena vacia no se envia Authorization"
|
||||
- name: pass
|
||||
desc: "password para Basic Auth; solo se usa cuando user != ''"
|
||||
- name: labels
|
||||
desc: "labels del stream Loki (ej {instance:lucas, job:journald, unit:ssh.service}); van tal cual en el campo stream"
|
||||
- name: timestampsNs
|
||||
desc: "timestamps en nanosegundos desde epoch, uno por linea; debe tener la misma longitud que lines"
|
||||
- name: lines
|
||||
desc: "lineas de log a enviar, alineadas posicionalmente con timestampsNs; si esta vacio la funcion es no-op"
|
||||
output: "error si la peticion falla, las longitudes no coinciden o el status no es 2xx; nil en exito (incluido el no-op de 0 lineas)"
|
||||
tested: true
|
||||
tests:
|
||||
- "JSON enviado tiene estructura streams/stream/values correcta"
|
||||
- "longitudes desiguales dan error antes del POST"
|
||||
- "len lines cero es no-op sin peticion"
|
||||
- "Basic Auth presente cuando user no vacio"
|
||||
- "status 500 produce error"
|
||||
test_file_path: "functions/infra/push_loki_stream_test.go"
|
||||
file_path: "functions/infra/push_loki_stream.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
labels := map[string]string{
|
||||
"instance": "lucas",
|
||||
"job": "journald",
|
||||
"unit": "ssh.service",
|
||||
}
|
||||
nowNs := time.Now().UnixNano()
|
||||
ts := []int64{nowNs, nowNs + 1}
|
||||
lines := []string{
|
||||
"Accepted publickey for lucas from 10.0.0.2",
|
||||
"session opened for user lucas",
|
||||
}
|
||||
|
||||
err := PushLokiStream(
|
||||
"https://logs-abcd.organic-machine.com/loki/api/v1/push",
|
||||
"tenant1", // user para Basic Auth (vacio = sin auth)
|
||||
"s3cr3t", // pass
|
||||
labels,
|
||||
ts,
|
||||
lines,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites enviar lineas de log a Grafana Loki desde un agente o servicio Go
|
||||
(ej. reenviar journald, eventos de una app, o lineas de un tailer) sin arrastrar el
|
||||
cliente oficial de Loki. Util para alimentar dashboards de la flota (`fleet-metrics`)
|
||||
con logs etiquetados por instancia/job/unit. Pasa los logs ya batcheados: un solo
|
||||
stream por llamada con sus labels.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `timestampsNs` y `lines` deben tener exactamente la misma longitud; si no, retorna
|
||||
error ANTES de hacer la peticion (no envia nada).
|
||||
- `len(lines)==0` es un no-op deliberado: retorna `nil` sin tocar la red. Comprueba el
|
||||
caso vacio en el caller si necesitas distinguir "no habia logs" de "envio ok".
|
||||
- Loki exige timestamps en NANOSEGUNDOS. Pasar segundos o milisegundos hace que las
|
||||
lineas caigan fuera de la ventana de retencion y Loki las rechace silenciosamente.
|
||||
- Dentro de un mismo stream las entradas deberian ir en orden creciente de timestamp;
|
||||
Loki puede rechazar entradas fuera de orden segun su config.
|
||||
- Exito = status 2xx (Loki normalmente devuelve 204 No Content). Un 4xx/5xx produce
|
||||
error con el codigo + primeros 200 bytes del cuerpo de respuesta para diagnostico.
|
||||
- TLS verificado (sin InsecureSkipVerify) y `http.Client` con timeout de 10s fijo. Para
|
||||
endpoints con certificado interno hace falta CA confiable en el sistema.
|
||||
- Secretos (`user`/`pass`): nunca hardcodear — resolver desde `pass`/vault en el caller.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PushPromRemote hace POST del body (texto en formato Prometheus exposition) al
|
||||
// endpoint dado, tipicamente el import de VictoriaMetrics
|
||||
// (".../api/v1/import/prometheus").
|
||||
//
|
||||
// - Si user != "" usa Basic Auth con user/pass.
|
||||
// - extraLabels se adjuntan como query params repetidos
|
||||
// "extra_label=clave=valor" URL-encoded. VictoriaMetrics añade esas labels a
|
||||
// TODAS las series del push (util para la label "instance").
|
||||
// - Content-Type: text/plain.
|
||||
// - http.Client con Timeout 10s y TLS verificado.
|
||||
// - Exito = status 2xx (VictoriaMetrics devuelve 204). Si no-2xx, retorna error
|
||||
// con el codigo y los primeros 200 bytes del cuerpo de respuesta.
|
||||
func PushPromRemote(endpoint string, user string, pass string, body string, extraLabels map[string]string) error {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse endpoint %q: %w", endpoint, err)
|
||||
}
|
||||
|
||||
if len(extraLabels) > 0 {
|
||||
q := u.Query()
|
||||
for k, v := range extraLabels {
|
||||
q.Add("extra_label", k+"="+v)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push to %q: %w", endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
|
||||
return fmt.Errorf("push to %q failed: status %d: %s", endpoint, resp.StatusCode, string(snippet))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: push_prom_remote
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func PushPromRemote(endpoint string, user string, pass string, body string, extraLabels map[string]string) error"
|
||||
description: "Hace POST de un body en formato Prometheus exposition a un endpoint remoto (ej VictoriaMetrics /api/v1/import/prometheus). Soporta Basic Auth (si user!=\"\"), adjunta extraLabels como query params repetidos extra_label=clave=valor (VictoriaMetrics los añade a todas las series del push), Content-Type text/plain, http.Client con Timeout 10s y TLS verificado. Exito = status 2xx; si no, error con codigo + primeros 200 bytes del cuerpo de respuesta."
|
||||
tags: [prometheus, push, victoriametrics, remote-write, fleet-metrics, infra, monitoring, http]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["fmt", "io", "net/http", "net/url", "strings", "time"]
|
||||
params:
|
||||
- name: endpoint
|
||||
desc: "URL completa del endpoint de ingestion, ej https://metrics-xxxx.organic-machine.com/api/v1/import/prometheus"
|
||||
- name: user
|
||||
desc: "usuario para Basic Auth; si es cadena vacia no se envia Authorization"
|
||||
- name: pass
|
||||
desc: "password para Basic Auth; se ignora si user esta vacio"
|
||||
- name: body
|
||||
desc: "texto en formato Prometheus exposition (tipicamente salida de format_prom_exposition)"
|
||||
- name: extraLabels
|
||||
desc: "labels a adjuntar a todas las series via extra_label, ej {\"instance\":\"lucas\"}; puede ser nil"
|
||||
output: "nil si el push devuelve status 2xx (VictoriaMetrics responde 204). Error si la request falla, el endpoint es invalido, o el status no es 2xx (con codigo y snippet del cuerpo)."
|
||||
tested: true
|
||||
tests:
|
||||
- "TestPushPromRemote"
|
||||
test_file_path: "functions/infra/push_prom_remote_test.go"
|
||||
file_path: "functions/infra/push_prom_remote.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
body := FormatPromExposition(samples, time.Now().UnixMilli())
|
||||
err := PushPromRemote(
|
||||
"https://metrics-xxxx.organic-machine.com/api/v1/import/prometheus",
|
||||
"ingest-user", "ingest-pass",
|
||||
body,
|
||||
map[string]string{"instance": "lucas-pc"},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("push fallo: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando un nodo de la flota tiene que **empujar** sus metricas a un backend
|
||||
central (VictoriaMetrics, Mimir, pushgateway) en vez de exponer un /metrics para
|
||||
scraping. Es el paso final del capability group `fleet-metrics`:
|
||||
collect_host_metrics -> format_prom_exposition -> push_prom_remote. Tipica en
|
||||
nodos detras de NAT, moviles (Termux) o cualquier host al que el servidor central
|
||||
no puede alcanzar para hacer pull.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **extra_label es clave=valor como un solo valor de query**: para {"instance":"lucas"}
|
||||
produce `?extra_label=instance%3Dlucas` (el `=` interno se URL-encodea a `%3D`).
|
||||
VictoriaMetrics lo aplica a todas las series del push; otros backends pueden no
|
||||
soportar este parametro.
|
||||
- **Secretos**: nunca hardcodees `user`/`pass` — resuelvelos desde `pass`/vault.
|
||||
- **TLS verificado** (sin InsecureSkipVerify): un endpoint con certificado
|
||||
autofirmado fallara. Usa un certificado valido o un proxy de confianza.
|
||||
- **Timeout 10s**: un backend lento o un body enorme puede dar timeout. Trocea
|
||||
pushes muy grandes si es necesario.
|
||||
- **204 No Content es exito**: VictoriaMetrics no devuelve 200. La funcion acepta
|
||||
cualquier 2xx; no asumas 200 al testear contra el real.
|
||||
- **El cuerpo de error se trunca a 200 bytes**: suficiente para diagnostico
|
||||
rapido, no para el detalle completo del backend.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -7,12 +7,15 @@ require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/marcboeker/go-duckdb v1.8.5
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/shirou/gopsutil/v4 v4.26.5
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
@@ -40,23 +43,24 @@ require (
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/google/flatbuffers v25.1.24+incompatible // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
@@ -67,6 +71,7 @@ require (
|
||||
github.com/paulmach/orb v0.12.0 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/rs/zerolog v1.35.1 // indirect
|
||||
@@ -76,7 +81,10 @@ require (
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.mau.fi/util v0.9.9 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
|
||||
@@ -47,6 +47,8 @@ github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
@@ -55,6 +57,8 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
@@ -70,6 +74,7 @@ github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLK
|
||||
github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -104,6 +109,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
|
||||
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
@@ -139,6 +146,8 @@ github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcR
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -147,6 +156,8 @@ github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
@@ -170,6 +181,10 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
@@ -182,6 +197,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
@@ -224,8 +241,10 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: PromSample
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
type PromSample struct {
|
||||
Name string // nombre de metrica prometheus, ej "node_cpu_percent"
|
||||
Labels map[string]string // labels de la serie, ej {"core":"0"} (sin la label "instance")
|
||||
Value float64
|
||||
}
|
||||
description: "Una unica serie de metrica en formato Prometheus: nombre, labels y valor numerico. Es la unidad que producen los colectores (collect_host_metrics) y consume el formateador (format_prom_exposition). NO incluye la label instance: esa la añade el pusher remoto via extra_label."
|
||||
tags: [prometheus, metrics, sample, fleet-metrics, infra, monitoring]
|
||||
uses_types: []
|
||||
file_path: "functions/infra/prom_sample.go"
|
||||
---
|
||||
|
||||
## Campos
|
||||
|
||||
- `Name`: nombre de la metrica en formato Prometheus, ej `node_cpu_percent`, `node_disk_used_bytes`. Se sanitiza a `[a-zA-Z0-9_:]` en el formateador.
|
||||
- `Labels`: mapa clave/valor de labels de la serie, ej `{"core":"0"}`, `{"mount":"/"}`. Puede ser `nil` o vacio (serie sin labels). NO debe incluir la label `instance` — esa se añade en el push remoto.
|
||||
- `Value`: valor numerico de la metrica. Se formatea con `strconv.FormatFloat(v, 'g', -1, 64)`.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
samples := []PromSample{
|
||||
{Name: "node_load1", Labels: nil, 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: 1.2e10},
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Es un tipo producto puro: solo datos, sin metodos.
|
||||
- Vive en `functions/infra/prom_sample.go` (mismo paquete Go que los colectores y el formateador) para evitar imports cruzados entre paquetes del registry.
|
||||
- Lo producen `collect_host_metrics_go_infra` y lo consume `format_prom_exposition_go_infra`.
|
||||
Reference in New Issue
Block a user