diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b8f7119 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# whatsapp-bridge skill — config local (NO versionar) +# +# Copiar a `.env` en este mismo directorio y completar. + +# Token bearer que usas para autenticarte con la API. +# Obtener de /srv/NucleOS/whatsapp-bridge/.env en nucleo001 (var API_TOKEN). +WA_BRIDGE_TOKEN= + +# (opcional) Override de la URL del bridge — útil si expones local en otro host. +# WA_BRIDGE_URL=https://whatsapp.nucleoriofrio.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d243996 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Local config con secrets — NUNCA committear +.env + +# Cache local +.cache/ + +# Temporales +*.tmp +*.bak +*.swp diff --git a/README.md b/README.md index db4d40f..a0e0953 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,50 @@ # skill-whatsapp -Skill de Claude Code para consultar y enviar mensajes de WhatsApp del beneficio Rio Frio (numero +504 9742-9865) via el servicio whatsapp-bridge desplegado en nucleo001. \ No newline at end of file +Skill de [Claude Code](https://claude.com/claude-code) para consultar y enviar +mensajes de WhatsApp del beneficio Rio Frio (número **+504 9742-9865**, nombre +"Nucleo🌐🧠🤖") vía el servicio `whatsapp-bridge` desplegado en nucleo001 +(`https://whatsapp.nucleoriofrio.com`). + +> Mirror público del directorio local `~/.claude/skills/whatsapp/` en la PC +> del usuario humano. Existe para que el usuario pueda **auditar** lo que el +> bot lee y envía. Clonar y correr en otra máquina **no funciona +> out-of-the-box** — requiere bearer token del bridge en `.env` (no incluido) +> y acceso al endpoint público. + +## Doc principal + +Ver **[SKILL.md](SKILL.md)** — flujos típicos, endpoints disponibles +(read + write), reglas de comportamiento. + +Cheat sheet en **[endpoints.md](endpoints.md)** y mapping de JIDs conocidos +en **[contacts.md](contacts.md)**. + +## Estructura + +``` +. +├── SKILL.md ← docs canónicas +├── endpoints.md ← cheat sheet API del bridge +├── contacts.md ← mapping JID → contacto/grupo conocido +├── .env.example +├── .gitignore +├── README.md ← este archivo +└── scripts/ + └── query.sh ← helper REST autenticado (Bearer token) +``` + +## Lo que NO está en este repo + +- `.env` (token del bridge) + +## Backend + +El servicio `whatsapp-bridge` (Baileys) corre como stack en nucleo001 — código +fuente y deploy: [NucleOS/whatsapp-bridge](https://gitea.nucleoriofrio.com/NucleOS/whatsapp-bridge) +(repo del backend, no de esta skill). + +## Dependencias + +- `curl` +- `python` (parsear JSON) +- Token válido en `.env` (`WA_BRIDGE_TOKEN`) diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..42ed77b --- /dev/null +++ b/SKILL.md @@ -0,0 +1,237 @@ +--- +name: whatsapp +description: Consulta y envía mensajes de WhatsApp del beneficio Rio Frio a través del servicio whatsapp-bridge desplegado en nucleo001 (https://whatsapp.nucleoriofrio.com). Úsala cuando el usuario pida buscar, resumir, listar, extraer o enviar información a chats o grupos de WhatsApp de ese número. Cubre casos como "qué se habló en el grupo X", "alguien mencionó retenciones esta semana", "cuál fue el último precio discutido", "mandale un mensaje a Y". +allowed-tools: Bash, Read, Grep +--- + +# WhatsApp Bridge — skill de consulta on-demand + +## Qué es este servicio + +`whatsapp-bridge` es un servicio de Nucleo que mantiene una sesión de WhatsApp +vía Baileys contra el número **+504 9742-9865** del beneficio Rio Frio. Guarda +todos los mensajes de los chats (individuales y grupos) a los que ese número +pertenece en Postgres, con retención infinita. + +La API expone consultas read + write. **El endpoint `/send` está habilitado**. + +- **Base URL**: `https://whatsapp.nucleoriofrio.com` +- **Auth**: `Authorization: Bearer $WA_BRIDGE_TOKEN` +- **El token vive en**: `~/.claude/skills/whatsapp/.env` (local, no versionado) + +**Nota sobre el endpoint MCP**: `https://whatsapp.nucleoriofrio.com/mcp` existe +para que Claude.ai web/móvil consuma el mismo servicio como Custom Integration. +**No lo uses tú desde Claude Code** — tu path principal es la API REST vía +`scripts/query.sh`. El MCP es solo para los otros clientes de Claude. + +## Cómo invocarla correctamente + +Antes de cualquier query: +1. Carga el token: `source ~/.claude/skills/whatsapp/.env` (o lee `$WA_BRIDGE_TOKEN` del entorno). +2. Si no hay token, avisa al usuario: pedir que cree `~/.claude/skills/whatsapp/.env` con `WA_BRIDGE_TOKEN=`. +3. Verifica estado: `GET /api/auth/status`. Si `status !== "open"`, **no hagas queries** — avísale al usuario que el bridge está caído o necesita re-pairing. + +El helper `scripts/query.sh` ya maneja el bearer: +```bash +~/.claude/skills/whatsapp/scripts/query.sh /api/auth/status +``` + +## Endpoints disponibles + +Todos bajo `https://whatsapp.nucleoriofrio.com` con `Authorization: Bearer $TOKEN`. + +### Diagnóstico + +- `GET /healthz` — liveness, sin auth. Úsalo para verificar que el servicio responde. +- `GET /api/auth/status` → `{ status, hasSession, user, pairingCode, lastError }`. + Valores de `status`: `idle | connecting | open | closed | needs-pair`. + +### Listar chats + +- `GET /api/chats?type=&q=&limit=100&offset=0` + - `type`: filtrar por tipo. Default `all`. + - `q`: búsqueda parcial por nombre del chat o JID. + - Responde: `{ count, chats: [{ jid, isGroup, name, lastMessageAt, participants }] }`. +- **Patrón típico**: cuando el usuario menciona un grupo por nombre (ej "el grupo de productores"), primero `GET /api/chats?type=group&q=productores` para resolver el JID, después consulta mensajes con ese JID. + +### Un grupo específico + +- `GET /api/groups/:jid` — metadata (live + stored). El `:jid` debe ser URL-encoded (termina en `@g.us`). +- `GET /api/groups/:jid/messages` — mensajes del grupo. Query params: + - `since=2026-04-14T00:00:00Z` (ISO timestamp) + - `until=2026-04-21T23:59:59Z` + - `q=` — búsqueda parcial ILIKE en el contenido + - `sender=` — filtrar por quién escribió (participant) + - `order=asc|desc` (default `desc`) + - `limit=100` (máx 1000), `offset=0` + - Responde: `{ jid, count, filters, messages: [{ msgId, fromMe, senderJid, pushName, messageType, content, contextInfo, timestamp }] }`. + +### Chats individuales + +- `GET /api/messages/individual/:jid?since=&until=&q=&order=&limit=&offset=` + - `:jid` termina en `@s.whatsapp.net`. + - Responde mensajes de ese chat. + +### Búsqueda cross-chat + +- `GET /api/messages/search?q=&since=&until=&chatType=&limit=100` + - Busca en TODO el historial. Úsalo cuando el usuario pide algo como "alguien mencionó retenciones" o "busca el precio por quintal de la semana pasada". + - Responde: `{ query, count, matches: [{ chatJid, msgId, senderJid, pushName, isGroup, content, timestamp }] }`. + - Para pasar de matches a contexto, obtén el `chatJid` de un match y pide `GET /api/groups/:jid/messages` con `since` unos minutos antes. + +### Endpoints de escritura + +- `POST /api/messages/send` — texto. + Body: `{ jid, text, quotedChatJid?, quotedMsgId?, mentions? }`. + `quotedMsgId` opcional para **responder** a un mensaje existente (pass quotedChatJid si es de otro chat). + `mentions` array de JIDs para @-menciones. + +- `POST /api/messages/forward` — reenviar un mensaje del historial a otro chat. + Body: `{ sourceChatJid, sourceMsgId, destJid }`. + El mensaje debe existir en nuestro storage (lo vio el socket en vivo). + +- `POST /api/messages/react` — reaccionar con emoji. + Body: `{ chatJid, msgId, emoji }`. Emoji vacío `""` = quitar reacción previa. + +- `POST /api/messages/send-media` — multimedia (image/video/audio/document). + Body: `{ jid, url, type, caption?, fileName?, mimetype?, ptt?, quotedChatJid?, quotedMsgId? }`. + La URL debe ser HTTP(S) público — IPs privadas bloqueadas por SSRF guard. `type` es obligatorio. + +- `POST /api/messages/send-location` — ubicación geográfica. + Body: `{ jid, latitude, longitude, name?, address?, quotedChatJid?, quotedMsgId? }`. + +- `POST /api/messages/send-poll` — encuesta. + Body: `{ jid, name, values[2..12], selectableCount?, quotedChatJid?, quotedMsgId? }`. + `selectableCount=1` (default) = single-choice; `=0` = multi-choice; `=N` = hasta N opciones. + +- `POST /api/messages/send-contact` — tarjeta(s) de contacto vCard. Hasta 20. + Body: `{ jid, contacts: [{ displayName, firstName?, lastName?, phone?, phoneDisplay?, email?, organization?, title?, vcard? }], quotedChatJid?, quotedMsgId? }`. + Si `phone` está en E.164 sin `+`, WhatsApp lo detecta como contacto vinculable (tap → chatear). + +**Eventos**: NO soportado todavía. Baileys 6.17 no tiene API estable para crear event messages. Si el user lo pide, avisale del estado actual. + +### Descarga de archivos (image/video/audio/document/sticker) + +Los mensajes en respuestas de `/groups/:jid/messages`, `/individual/:jid`, `/search` incluyen un campo `media` cuando contienen archivo: + +```json +"media": { + "type": "image", + "mimetype": "image/jpeg", + "size": 123456, + "caption": "...", + "downloadUrl": "/api/messages/media//" +} +``` + +Para obtener el archivo **desencriptado**: + +```bash +~/.claude/skills/whatsapp/scripts/query.sh \ + "/api/messages/media//" \ + -o /tmp/wa-image.jpg +``` + +Eso guarda el binario. `query.sh` pasa el bearer automáticamente; la ruta que copies del campo `downloadUrl` ya funciona. + +Para solo metadata sin descargar: `GET /api/messages/media-info/:chatJid/:msgId`. + +Todos retornan `{ ok: true, msgId: "..." }` al éxito, 4xx/5xx con `{ error: "..." }` si falla. +Si retornan 403 significa que `ENABLE_SEND=false` — hay que cambiarlo en `stack.yml` y redeployar. + +### Pairing (setup / recovery) + +- `POST /api/auth/pair` → devuelve `{ code }` (válido ~60s). + - El usuario toma su teléfono con el número, abre WhatsApp > Ajustes > Dispositivos vinculados > Vincular con número, y teclea el código. +- `POST /api/auth/logout` con header `X-Confirm-Wipe: yes` → borra la sesión. **DESTRUCTIVO**: después hay que re-pairear. Nunca llamar esto salvo que el usuario lo pida explícitamente. + +## Reglas de comportamiento + +### Antes de ejecutar queries +- Si el status no es `open`, avísale al usuario y no consumas la API en vano. +- Si el usuario menciona un grupo por nombre, resuelve el JID primero con `/api/chats?type=group&q=…` y pídele confirmar "¿te refieres a este grupo: ?". + +### Cuándo usar qué endpoint +- **Resumir un grupo en una ventana de tiempo** → `/api/groups/:jid/messages?since=&until=` con `order=asc` para leer en orden cronológico. +- **Buscar menciones** (palabra clave) → `/api/messages/search?q=` primero; si hay resultados, fetch contexto con `/api/groups/:jid/messages?since=&until=` alrededor del match. +- **Ver actividad reciente** → `/api/chats?type=group&limit=20` ordena por `lastMessageAt` desc. +- **Perfilar un contacto** (cuánto escribió, en qué chats) → `/api/messages/search?q=&sender=…` (no implementado aún — si es necesario, usa el endpoint de grupo con `sender=` repetidamente). + +### Cómo mostrar resultados al usuario +- Los JIDs (`549…@g.us`) son ruido visual. Muestra `pushName` o `name` del chat, no el JID, salvo que el usuario lo pida explícitamente. +- Agrupa por chat cuando haya resultados de múltiples chats. +- Si la lista es larga (>30 mensajes), resume en lugar de volcar todo. +- Incluye el `timestamp` en formato humano (ej `2026-04-21 15:30`) y `pushName` del autor. +- Mensajes con `fromMe: true` son del número del bridge (no los confundas con respuestas de otros). + +### Errores típicos y cómo reaccionar +- **401 unauthorized** → el token está mal. Revisa `WA_BRIDGE_TOKEN` en `~/.claude/skills/whatsapp/.env`. +- **503 socket not ready** → el bridge está caído o desconectado. `GET /api/auth/status` para diagnosticar. Si `status=needs-pair`, el usuario tiene que re-pairear. +- **404 en un JID** → el chat no existe en nuestro storage. El número puede no estar en ese grupo, o el JID está mal escrito. + +## Ejemplos de sesiones + +### Caso: "Resume lo que se habló hoy en el grupo de operaciones" +```bash +# 1. Resolver el JID +~/.claude/skills/whatsapp/scripts/query.sh '/api/chats?type=group&q=operaciones' +# → { chats: [{ jid: "123@g.us", name: "Operaciones Rio Frio", ... }] } + +# 2. Pedir mensajes de hoy +TODAY=$(date -u +%Y-%m-%dT00:00:00Z) +~/.claude/skills/whatsapp/scripts/query.sh "/api/groups/123%40g.us/messages?since=$TODAY&order=asc&limit=500" +# → resumir manualmente en la respuesta al usuario +``` + +### Caso: "¿Alguien mencionó el precio por quintal esta semana?" +```bash +WEEK_AGO=$(date -u -d '7 days ago' +%Y-%m-%dT00:00:00Z) +~/.claude/skills/whatsapp/scripts/query.sh "/api/messages/search?q=quintal&since=$WEEK_AGO&chatType=group" +# → presentar los matches agrupados por chat con timestamp y autor +``` + +### Caso: "Envía un mensaje al grupo X diciendo Y" +POST: +```bash +~/.claude/skills/whatsapp/scripts/query.sh -X POST \ + -H 'Content-Type: application/json' \ + -d '{"jid":"123@g.us","text":"..."}' /api/messages/send +``` +Si responde 403, avisale al user que `ENABLE_SEND=false` en `stack.yml`. + +### Caso: "Responde a ese mensaje con X" +Pasá `quotedMsgId` (y opcional `quotedChatJid` si es de otro chat): +```bash +~/.claude/skills/whatsapp/scripts/query.sh -X POST \ + -H 'Content-Type: application/json' \ + -d '{"jid":"123@g.us","text":"claro, ya vamos","quotedMsgId":"ABCD..."}' \ + /api/messages/send +``` + +### Caso: "Reenvía ese mensaje al grupo de productores" +1. Obtené el JID del grupo destino (vía `/api/chats`). +2. POST a `/api/messages/forward` con `{ sourceChatJid, sourceMsgId, destJid }`. + +### Caso: "Reacciona con 👍 a ese mensaje" +```bash +~/.claude/skills/whatsapp/scripts/query.sh -X POST \ + -H 'Content-Type: application/json' \ + -d '{"chatJid":"123@g.us","msgId":"ABCD","emoji":"👍"}' \ + /api/messages/react +``` + +### Caso: "Mandá esa imagen con el caption X" +El usuario te pasa una URL pública (imgur, Gitea attachment, etc.): +```bash +~/.claude/skills/whatsapp/scripts/query.sh -X POST \ + -H 'Content-Type: application/json' \ + -d '{"jid":"123@g.us","url":"https://i.imgur.com/X.jpg","type":"image","caption":"..."}' \ + /api/messages/send-media +``` +Si no tiene URL pública y el archivo está local, primero hay que subirlo a algún lado accesible. No tenemos (aún) endpoint de upload directo multipart. + +## Archivos de referencia en esta skill +- `endpoints.md` — tabla de referencia rápida de todos los endpoints y sus parámetros. +- `contacts.md` — **LEELO** siempre que vayas a mostrar o enviar a un JID; contiene el mapping de identificadores a personas conocidas. +- `scripts/query.sh` — helper de curl autenticado. +- `.env` (no versionado) — contiene `WA_BRIDGE_TOKEN=`. diff --git a/contacts.md b/contacts.md new file mode 100644 index 0000000..ea1259d --- /dev/null +++ b/contacts.md @@ -0,0 +1,27 @@ +# Contactos conocidos — mapping JID → persona/grupo + +Consultá este archivo **siempre** que una respuesta del bridge incluya un JID o +LID, para mostrarle al usuario un nombre humano en lugar del identificador crudo. +Actualizá la tabla cuando se vinculen grupos o aparezcan contactos recurrentes. + +## Individuales + +| JID | Nombre en WA | Es | Notas | +|---|---|---|---| +| `260300838305975@lid` | 📀🧮📡🌐 | **El usuario** (jodarioel87, owner de NucleOS) | Al enviarle mensajes, van a su WhatsApp personal. Default destinatario cuando el user dice "mándame X" sin especificar. | +| `50497429865@s.whatsapp.net` | — | Número propio del bridge (self-chat) | Al vincular un dispositivo, WhatsApp crea este entry. No es un chat funcional. Ignorar en listados. | + +## Grupos + +(vacío — el número recién vinculado, aún no participa en grupos) + +Cuando el bridge detecte grupos nuevos (via `groups.upsert` event), van a aparecer +en `GET /api/chats?type=group`. Agregalos acá con una nota del propósito. + +## Mi propia identidad (el bridge) + +- JID: `50497429865:3@s.whatsapp.net` +- LID: `265300851306681:3@lid` +- Nombre: **Nucleo🌐🧠🤖** + +Cuando veas `fromMe: true` en un mensaje, es el bridge (= yo, actuando bajo órdenes del usuario). diff --git a/endpoints.md b/endpoints.md new file mode 100644 index 0000000..d4f3612 --- /dev/null +++ b/endpoints.md @@ -0,0 +1,304 @@ +# whatsapp-bridge — referencia rápida de endpoints + +Base URL: `https://whatsapp.nucleoriofrio.com` +Auth: `Authorization: Bearer $WA_BRIDGE_TOKEN` (excepto `/healthz`) + +## Diagnóstico + +| Método | Ruta | Respuesta | +|---|---|---| +| GET | `/healthz` | `{ ok: true, ts }` | +| GET | `/api/auth/status` | `{ status, hasSession, user, pairingCode, lastError }` | + +`status` ∈ `idle | connecting | open | closed | needs-pair`. + +## Auth / pairing + +| Método | Ruta | Body / Headers | +|---|---|---| +| POST | `/api/auth/pair` | — → `{ code, hint }` | +| POST | `/api/auth/logout` | Header `X-Confirm-Wipe: yes` (destructivo) | + +## Chats + +``` +GET /api/chats + ?type=group|individual|all (default all) + &q= (busca en name + jid, ILIKE) + &limit=<1..500> (default 100) + &offset= (default 0) +``` + +Respuesta: +```json +{ + "count": N, + "chats": [ + { "jid": "...@g.us", "isGroup": true, "name": "...", "lastMessageAt": "ISO", "participants": 42 } + ] +} +``` + +## Grupo específico + +``` +GET /api/groups/:jid (URL-encode el jid, termina en @g.us) +``` +Respuesta: `{ jid, stored: {…}, live: GroupMetadata | null }`. + +``` +GET /api/groups/:jid/messages + ?since= (timestamp >=) + &until= (timestamp <=) + &q= (ILIKE en content) + &sender= (filtra por participant) + &order=asc|desc (default desc) + &limit=<1..1000> (default 100) + &offset= +``` + +Respuesta: +```json +{ + "jid": "...@g.us", + "count": N, + "filters": { ... }, + "messages": [ + { + "msgId": "...", + "fromMe": false, + "senderJid": "...@s.whatsapp.net", + "pushName": "Don Fulano", + "messageType": "extendedTextMessage", + "content": "texto extraído", + "contextInfo": { "quotedMsgId": "...", "mentionedJid": ["..."] } | null, + "timestamp": "ISO", + "media": null | { + "type": "image"|"video"|"audio"|"document"|"sticker", + "mimetype": "image/jpeg", + "size": 12345, + "fileName": "... (solo document)", + "caption": "... (image/video/document)", + "durationSec": 30 (video/audio), + "ptt": true (solo audio = voice note), + "width": 800 (image/video/sticker), + "height": 600, + "pages": 5 (solo document), + "isAnimated": true (solo sticker), + "downloadUrl": "/api/messages/media//" + } + } + ] +} +``` + +El campo `media` aparece en cada mensaje que contiene archivo; `downloadUrl` es la ruta relativa al endpoint que descarga + desencripta. + +## Mensajes individuales + +``` +GET /api/messages/individual/:jid (termina en @s.whatsapp.net) + ?since=&until=&q=&order=&limit=&offset= +``` + +## Búsqueda cross-chat + +``` +GET /api/messages/search + ?q= (requerido, min 1 char, ILIKE) + &since= + &until= + &chatType=group|individual|all (default all) + &limit=<1..500> (default 100) +``` + +Respuesta: +```json +{ + "query": "quintal", + "count": N, + "matches": [ + { "chatJid": "...", "msgId": "...", "senderJid": "...", "pushName": "...", "isGroup": true, "content": "...", "timestamp": "ISO" } + ] +} +``` + +## Envío de texto (gated por ENABLE_SEND) + +``` +POST /api/messages/send +Content-Type: application/json + +{ + "jid": "...@g.us" | "...@s.whatsapp.net" | "...@lid", + "text": "...", + "quotedChatJid": "...", // opcional; default = mismo jid destino + "quotedMsgId": "...", // opcional; responder a mensaje previo + "mentions": ["jid1", ...] // opcional; notifica a esos JIDs +} +``` + +Respuesta: `{ ok: true, msgId: "..." }` o 403 si ENABLE_SEND=false. + +## Reenvío (forward) + +``` +POST /api/messages/forward + +{ + "sourceChatJid": "...", // chat donde está el mensaje original + "sourceMsgId": "...", // id del mensaje + "destJid": "..." // destino +} +``` + +Requiere que el mensaje esté en la DB del bridge (escuchado mientras el socket estaba vivo). + +## Reacción (emoji) + +``` +POST /api/messages/react + +{ + "chatJid": "...", + "msgId": "...", + "emoji": "👍" // vacío "" = quitar reacción +} +``` + +## Envío de multimedia + +``` +POST /api/messages/send-media + +{ + "jid": "...", + "url": "https://...", // URL pública del archivo + "type": "image" | "video" | "audio" | "document", + "caption": "...", // opcional (solo image/video/document) + "fileName": "...", // opcional (solo document) + "mimetype": "...", // opcional (override del content-type) + "ptt": false, // solo audio: true = voice note + "quotedChatJid": "...", // opcional + "quotedMsgId": "..." // opcional +} +``` + +Límites y guards: +- **URL debe ser HTTP/HTTPS pública** — localhost, 127.0.0.1, 10.x, 192.168.x, 172.16-31.x son bloqueadas (SSRF protection). +- **Tamaño máximo ~100 MB** (aunque WhatsApp limita más bajo según tipo). +- Timeout del fetch: 60s. + +## Envío de ubicación + +``` +POST /api/messages/send-location + +{ + "jid": "...", + "latitude": 14.123, + "longitude": -88.456, + "name": "...", // opcional (ej: "Beneficio Rio Frio") + "address": "...", // opcional (ej: "Santa Bárbara, Honduras") + "quotedChatJid": "...", + "quotedMsgId": "..." +} +``` + +## Envío de encuesta (poll) + +``` +POST /api/messages/send-poll + +{ + "jid": "...", + "name": "Pregunta", + "values": ["Opción A", "Opción B", "Opción C"], // 2-12 opciones + "selectableCount": 1, // 1=single (default), 0=multi-choice, N=hasta N + "quotedChatJid": "...", + "quotedMsgId": "..." +} +``` + +## Envío de contacto(s) (vCard) + +``` +POST /api/messages/send-contact + +{ + "jid": "...", + "contacts": [ + { + "displayName": "Don Fulano", + "firstName": "Fulano", // opcional + "lastName": "Pérez", // opcional + "phone": "50499999999", // opcional, E.164 sin + → vinculable a WA + "phoneDisplay": "+504 9999-9999", // opcional, default = "+" + phone + "email": "...", // opcional + "organization": "...", // opcional + "title": "...", // opcional + "vcard": "BEGIN:VCARD..." // opcional, override total + } + ], + "quotedChatJid": "...", + "quotedMsgId": "..." +} +``` + +Hasta 20 contactos por mensaje. Si `phone` está en formato E.164 (sin `+`), WhatsApp lo detecta y permite iniciar chat con un tap. + +## Descarga de media (desencriptado server-side) + +Los mensajes con media incluyen `media.downloadUrl` en las respuestas. Ese path +apunta a un endpoint que, al llamarlo con el bearer, descarga el archivo del +CDN de WhatsApp, lo desencripta con la `mediaKey` del mensaje, y devuelve el +binario listo para usar. + +``` +GET /api/messages/media/:chatJid/:msgId[?disposition=inline|attachment] +``` + +Headers de respuesta: +- `Content-Type`: MIME correcto (image/jpeg, video/mp4, etc.) +- `Content-Length`: tamaño en bytes +- `Content-Disposition`: `inline` (default) o `attachment` +- `X-Wa-Media-Type`: `image|video|audio|document|sticker` + +Solo metadata (sin bajar el binario): + +``` +GET /api/messages/media-info/:chatJid/:msgId +→ { chatJid, msgId, media: { type, mimetype, size, fileName, caption, ..., downloadUrl } } +``` + +Errores: +- 404 si el mensaje no existe o no tiene media +- 503 si el socket de WhatsApp está caído +- 500 si el CDN de WA ya no tiene el archivo (muy viejo → no se puede recuperar) + +## Eventos (no soportado todavía) + +Baileys 6.17 no expone una API estable para crear event messages (los puede recibir/parsear pero no enviar via `sendMessage`). Sería necesario construir `proto.Message.IEventMessage` manualmente y usar `relayMessage` low-level — todavía no implementado en el bridge. Si lo necesitás urgente, abrir issue. + +Errores típicos: 400 con `fetch failed: ...` si la URL no responde bien o es privada. + +## Formatos de JID + +| JID | Tipo | Ejemplo | +|---|---|---| +| `@s.whatsapp.net` | chat individual | `50499999999@s.whatsapp.net` | +| `-@g.us` | grupo | `50499999999-1614182736@g.us` | +| `status@broadcast` | estados | (ignorado por el bridge) | + +URL-encoding en path params: `@` → `%40`, `.` se deja tal cual. + +## Códigos de error + +| Status | Significado | +|---|---| +| 400 | parámetros inválidos (ver body con `.error`) | +| 401 | bearer ausente o inválido | +| 403 | endpoint gated (ej send cuando ENABLE_SEND=false) | +| 404 | chat/grupo no existe en storage | +| 500 | error interno (revisar logs del contenedor) | +| 503 | socket no listo (status != open) | diff --git a/scripts/query.sh b/scripts/query.sh new file mode 100644 index 0000000..684bf17 --- /dev/null +++ b/scripts/query.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Helper para llamar al whatsapp-bridge API con el bearer ya aplicado. +# +# Uso: +# query.sh # GET +# query.sh -X POST -d '' # POST con body +# query.sh -H 'X-Confirm-Wipe: yes' -X POST /api/auth/logout +# +# Requiere: +# ~/.claude/skills/whatsapp/.env con WA_BRIDGE_TOKEN= +# (opcional) WA_BRIDGE_URL (default https://whatsapp.nucleoriofrio.com) + +set -euo pipefail + +ENV_FILE="${HOME}/.claude/skills/whatsapp/.env" +if [ ! -f "$ENV_FILE" ]; then + echo "ERROR: $ENV_FILE missing. Create it with WA_BRIDGE_TOKEN=" >&2 + exit 2 +fi + +# shellcheck disable=SC1090 +set -a; . "$ENV_FILE"; set +a + +if [ -z "${WA_BRIDGE_TOKEN:-}" ]; then + echo "ERROR: WA_BRIDGE_TOKEN not set in $ENV_FILE" >&2 + exit 2 +fi + +BASE="${WA_BRIDGE_URL:-https://whatsapp.nucleoriofrio.com}" + +# El último argumento es el path; todo lo anterior son flags de curl pasados through. +args=() +path="" +while [ $# -gt 0 ]; do + case "$1" in + --) shift; break ;; + -*) args+=("$1"); [ $# -ge 2 ] && args+=("$2") && shift; shift ;; + *) path="$1"; shift ;; + esac +done +# Si quedó algún argumento posicional al final (tras --) +[ $# -gt 0 ] && path="${path:-$1}" + +if [ -z "$path" ]; then + echo "Usage: query.sh [curl flags] " >&2 + exit 1 +fi + +# Asegura que path empiece con / +case "$path" in + /*) ;; + http*) echo "ERROR: pass only the path, not a full URL" >&2; exit 1 ;; + *) path="/$path" ;; +esac + +exec curl -fsS \ + -H "Authorization: Bearer ${WA_BRIDGE_TOKEN}" \ + -H "Accept: application/json" \ + "${args[@]}" \ + "${BASE}${path}"