# Cómo conectamos WhatsApp — instrucciones para el agente de Juan Pablo

Esto es el traspaso completo de cómo montamos las dos conexiones de WhatsApp que
hoy están vivas (la de la plataforma PDU y la de Sergio). No es teoría: es lo que
funcionó, con los errores que costaron horas y cómo se evitan.

**Lee todo antes de escribir una línea.** La mitad de este documento son trampas
que ya pisamos; ninguna es obvia y todas cuestan medio día.

Implementación de referencia: repo **`DCEO-CO/pdu-wa-bridge`** (Sergio te da
acceso). Clónalo y léelo — este documento explica el *por qué*; el código es el *qué*.

---

## 0. Primero decide el camino: API oficial o puente

| | **Meta Cloud API (oficial)** | **Puente Baileys (no oficial)** |
|---|---|---|
| Lee **grupos** | ❌ **NO puede** | ✅ sí |
| Número | uno dedicado, migrado a la API | el WhatsApp normal de la persona |
| Riesgo | cero | va contra los términos de Meta |
| Costo | por conversación | cero |
| Primer contacto en frío | plantillas aprobadas | libre (y por eso más riesgoso) |

**La regla:** si lo que necesita el cliente es **leer grupos** o **leer el WhatsApp
que ya usa**, la API oficial no sirve y toca puente. Para cualquier otra cosa
—notificaciones, campañas, atención saliente— usa Cloud API y no te metas en esto.

Las dos conexiones que tenemos son puentes porque la operación de los dos dueños
(Néstor de PDU, Sergio) vive en grupos de WhatsApp.

---

## 1. Reglas de oro (no negociables)

1. **Solo lectura por defecto.** Ni una llamada a `sendMessage`. Los baneos los
   disparan los ENVÍOS, no la escucha. Si después hace falta enviar, se agrega
   como módulo aparte y acotado (ver §7).
2. **Proxy residencial/ISP del país del número, obligatorio en producción.**
   WhatsApp marca las IPs de datacenter — es la bandera roja más grande. El número
   de un cliente **nunca** se conecta desde la IP pelada de un VPS. Sin proxy solo
   se permite un POC con un número desechable.
   Usamos **IPRoyal** (ISP sticky). En PDU: `168.158.173.112:12323` (US, porque el
   número de Néstor es de US). **La IP tiene que ser del país del número.**
3. **Consentimiento explícito del dueño.** Es su WhatsApp personal: por ahí pasan
   sus chats privados, su familia, su médico. Él autoriza qué se lee y qué se
   guarda, por escrito, antes de generar el QR.
4. **Filtrar, no aspirar todo.** Por defecto solo grupos, y si se puede, solo los
   grupos que él nombre.
5. **Un número = una carpeta = un `auth/`.** Nunca compartas carpeta ni sesión
   entre dos números (ver gotcha #0, nos costó 8 horas de captura dos veces).

---

## 2. Arquitectura: siempre DOS piezas separadas

```
WhatsApp del dueño
   │  dispositivo vinculado · Baileys · proxy residencial
   ▼
PUENTE (Node)  ──POST multipart, header X-WA-Secret──►  PLATAFORMA/HUB (tu app)
  escucha · filtra · baja adjuntos                        valida · excluye · guarda · muestra
```

**Están separados a propósito.** El puente es la pieza frágil: se desvincula, lo
banean, hay que re-escanear. Si vive dentro de tu plataforma, cada problema del
puente tumba la plataforma. Separados, si el puente muere lo capturado sigue intacto
y la plataforma no se entera.

**La plataforma expone UN punto de entrada** (`POST /webhooks/whatsapp/ingest`) y
toda la lógica de negocio vive detrás. El puente no sabe nada del negocio: escucha,
filtra y hace POST.

### Contrato del webhook (multipart/form-data)

| Campo | Qué es |
|---|---|
| `wa_from` | teléfono del remitente, sin sufijos |
| `wa_name` | `pushName` de WhatsApp |
| `group_name` / `chat_name` | nombre del grupo / del chat |
| `chat_jid` | `1203...@g.us` (grupo) o `57300...@s.whatsapp.net` (directo) |
| `is_group`, `from_me` | `"true"` / `"false"` |
| `body` | el texto o el caption |
| `wa_message_id` | **la llave de deduplicación** — guárdala con índice único |
| `media_type` | `image` · `video` · `audio` · `document` · `none` |
| `sent_at` | epoch en segundos (lo que da Baileys) |
| `file` | el adjunto, si lo hay |

Header `X-WA-Secret` con un secreto compartido. Si no coincide → 401.

**En el receptor, el orden importa:** (1) valida secreto → (2) aplica exclusión →
(3) política de adjuntos → (4) dedup por `wa_message_id` → (5) guarda.
La exclusión va **antes de escribir nada**: de un chat vetado no debe quedar ni
texto, ni archivo, ni registro. Ese es el control de privacidad real, y es lo que
le puedes mostrar al dueño del número cuando pregunte.

---

## 3. Montarlo (Docker, en el VPS)

```bash
git clone <repo> /opt/juan-pablo/<cliente>-wa-bridge
cd /opt/juan-pablo/<cliente>-wa-bridge
cp .env.example .env    # llénalo (ver §4)
mkdir -p auth
docker compose up -d --build
docker compose logs -f  # ← acá sale el QR
```

El `docker-compose.yml` no expone puertos: el puente solo **sale**. La sesión se
persiste en `./auth` montado como volumen — con eso reconecta sin re-escanear.
Ponle `--label owner=juan-pablo` (convención del vps-map del Contabo).

**Vincular:** el dueño abre *WhatsApp > Dispositivos vinculados > Vincular
dispositivo* y escanea el QR de los logs. Mejor todavía: manda el QR como
`dataURL` a tu plataforma (`postStatus('qr', {qr_png})`) y que lo escanee desde una
página, sin que tú le pases una foto de una terminal.

`browser: ['Nombre del Hub', 'Chrome', '1.0']` — así se ve en su lista de
dispositivos vinculados. Ponle un nombre que él reconozca, no "Chrome" a secas.

---

## 4. Variables

| Variable | Para qué |
|---|---|
| `PDU_WEBHOOK_URL` / `..._SECRET` | destino y secreto compartido (renómbralas por cliente) |
| `WA_PROXY_URL` | `socks5://user:pass@host:puerto` o `http://…`. **Obligatorio en prod** |
| `WA_ALLOWED_GROUPS` | subcadenas del nombre del grupo; **vacío = todos** |
| `WA_ALLOWED_SENDERS` | subcadenas de teléfono, para chats directos |
| `WA_CAPTURE_DIRECT` | `false` por defecto (solo grupos) |
| `WA_CAPTURE_FROM_ME` | `true` — el dueño se reenvía cosas a sí mismo |
| `WA_ONLY_MEDIA` | `true` = solo mensajes con adjunto |
| `WA_SYNC_HISTORY` | traer el histórico **al vincular** (ver gotcha #6) |
| `WA_HISTORY_MEDIA_DAYS` | `3` — solo intenta bajar adjuntos de los últimos N días |
| `WA_STALE_MIN` | `30` — minutos de silencio antes de matarse (gotcha #3) |

IPRoyal entrega proxy **HTTP**, no SOCKS. El código elige el agente según el
esquema de la URL (`socks…` → `SocksProxyAgent`, si no → `HttpsProxyAgent`); el
túnel CONNECT sirve igual para el WSS de WhatsApp.

---

## 5. Los ocho gotchas que costaron horas

Estos no los vas a deducir leyendo la doc de Baileys. Todos nos pasaron entre el
5 y el 13 de agosto de 2026.

**#0 · No compartas carpeta ni `auth/` entre dos números.** Empezamos con un solo
repo para PDU y para Sergio. Trabajar sobre uno **borró la sesión del otro dos
veces**: 8 horas de captura perdidas cada vez. Un número, un repo, un `auth/`,
un container.

**#1 · `creds.registered` NUNCA es `true` en un dispositivo vinculado.** Baileys
solo lo marca en registro primario. Si lo usas para detectar "ya estoy emparejado",
cada caída toma la rama de emparejamiento: **296 `start()` apilados en 20 horas**.
👉 **La señal correcta es `creds.me`.**

**#2 · Ya emparejado: NO reconectes dentro del proceso.** `setTimeout(start, …)`
crea un socket nuevo sin cerrar el anterior. Se apilaron **63 sockets**, todos
fallando con 408, y la sesión se corrompió (**91.950 errores "Bad MAC"**).
👉 `process.exit(1)` y que el supervisor (launchd/`restart: unless-stopped`)
rearranque limpio. La sesión persiste en `auth/`, así que no pide QR.
👉 **PERO durante el emparejamiento sí hay que reconectar en proceso**: si te
mueres mientras la persona tiene el QR en pantalla, lo invalidas. Nos pasó: **13
QRs inútiles seguidos**. Por eso la rama `if (!state.creds?.me)` reintenta en
proceso, y solo la otra sale.

**#3 · El modo de falla peligroso es el ZOMBI, no el crash.** Proceso vivo,
"conectado", entregando cero. Costó 20 horas de silencio.
👉 **Vigía de silencio:** si no pasa NADA por el socket (`messages.upsert`,
`chats.update`, `presence.update`, …) en 30 min, `exit(1)`.
👉 Ojo: capturar `unhandledRejection` **sin** el vigía convierte un fallo ruidoso
en uno silencioso — o sea, lo empeora. Van juntos o no van.

**#4 · Sesión degradada sin re-escanear QR.** Si empiezan los "Bad MAC": borra
`session-*.json` y `sender-key-*.json` **conservando** `creds.json` y
`app-state-sync-key-*`. Nos bajó de 91.950 errores a 1. No resucita una sesión
totalmente destruida, pero evita pedirle otro QR al cliente.

**#5 · La base de datos se ahoga con el backfill.** El sync empuja miles de
mensajes en minutos. Con el pool por defecto de SQLAlchemy (5+10) empezaron los
500 y **se perdieron mensajes**.
👉 pool 25+50, `pool_timeout=60`, y en SQLite **WAL** + `busy_timeout`. Después:
cero rechazos. Procesa el histórico **en serie** (el dedup hace inofensivo repetir).

**#6 · El histórico solo llega AL VINCULAR.** `syncFullHistory: true` tiene que
estar puesto **antes** de escanear el QR — después no se puede pedir.
`fetchMessageHistory` existe pero necesita un mensaje ancla por chat. Y aun así
llega parcial: la primera vez trajo **58 de 573 chats**.
👉 Decide el histórico ANTES de generar el QR. Si te equivocas, la única forma de
reintentar es desvincular y volver a escanear.
👉 Los nombres de grupo llegan en el evento `messaging-history.set` (array
`chats`): siembra ahí tu cache o vas a hacer miles de `groupMetadata()` y te
rate-limitean.

**#7 · Adjuntos viejos están caídos del lado de WhatsApp.** No intentes bajar el
media del histórico completo: perdés horas para nada. Tope de 3 días
(`WA_HISTORY_MEDIA_DAYS`).

**Extra (solo si corre en Mac con launchd):** launchd **no puede ejecutar desde
`~/Documents`** (TCC de macOS) — sale con código 78 "Function not implemented" y
ni escribe log; por eso el puente de Sergio vive en `~/.lg-wa-bridge`. Y
`launchctl bootstrap` falla con "Input/output error" justo después del `bootout`
**dejando el job descargado** (= puente muerto y en silencio): reintenta en bucle
con 4s de pausa.

---

## 6. Guarda UTC, renderiza local

Guarda todo en UTC y convierte a la zona del cliente al mostrar. Mezclar eso
después, con 70.000 mensajes adentro, no es divertido.

---

## 7. Si además necesita ENVIAR

Lo agregamos solo para el puente de Sergio, el 13-ago-2026, y con estas
restricciones a propósito:

- Servidor HTTP que escucha **solo en `127.0.0.1`** — no sale de la máquina.
- **Token obligatorio**; sin él el servidor ni arranca. Borrar el token = vuelve a
  solo lectura.
- **Solo texto.** Nada de archivos ni audios: menos superficie, menos daño posible
  por un bug.
- **Máximo 1 envío por segundo**, 4000 caracteres. El volumen es lo que dispara
  bloqueos.
- **El contenido y el destinatario los aprueba una persona ANTES de la llamada.**
  Es una acción externa, irreversible y sale con el nombre del dueño.
- Si el que responde es un agente, **el mensaje lleva firma** ("respondido por el
  agente de X"). Eso es lo que hace aceptable el envío autónomo: no se hace pasar
  por la persona.

**⚠️ El gotcha del envío:** Baileys **no emite `messages.upsert` para lo que el
propio socket envía**. Lo que mandes por el puente **no va a aparecer en tu
bandeja**, y releer tu API para verificar va a dar 0. No es que falle: es que el
hub no lo ve. El único acuse es el `id` que devuelve WhatsApp (`3EB0…`) y la línea
del log. Para confirmar de verdad, que una persona mire el chat.
(`WA_CAPTURE_FROM_ME=true` sí captura lo que el dueño escribe **desde su celular**,
porque eso llega como sincronización al dispositivo vinculado. Es otra cosa.)

---

## 8. Qué NO hacer

- ❌ **No metas Chatwoot ni whatsapp-cloud-inbox.** Los evaluamos: son aplicaciones
  completas que quieren SER dueñas de la conexión de WhatsApp. Si el cliente ya
  tiene la conexión en otro lado (GHL, Meta), adoptarlos te obliga a pelear el
  webhook y levantar otra plataforma en otro subdominio. Cópiales la lógica (la
  ventana de 24h, por ejemplo), no el código.
- ❌ **No rsync/scp el código al VPS.** Git siempre, con un `deploy.sh` en el
  servidor que hace `fetch` + `reset --hard origin/main` + `up -d --build`.
- ❌ **No pases el env con `env_file` en compose si el proyecto ya usa env
  explícito.** Al agregar una variable hay que listarla también en el compose, o
  el container no la ve y el fallo es silencioso.
- ❌ **No conectes el número de un cliente sin proxy residencial** ni sin su
  autorización escrita.
- ❌ **No dejes la bandeja sin login si tiene datos de terceros.** La de Sergio no
  tiene, y es una decisión suya sobre su propio WhatsApp — no la copies para un
  cliente.
- ❌ **No ingieras conversaciones que no son del alcance.** Nos pasó: el primer
  sync se trajo 71 conversaciones de **pacientes** de un cliente médico, porque la
  misma línea atiende pacientes y prospectos. Se borraron y se acotó la ingesta a
  contactos que ya estaban en nuestra tabla. Piensa en esto **antes** del primer
  sync, no después.

---

## 9. Antes de decir "listo"

- [ ] `docker compose logs` muestra `Conectado como dispositivo vinculado`
- [ ] Mensaje de prueba en un grupo permitido → aparece en la plataforma
- [ ] Mensaje en un chat **excluido** → **no** aparece, y no dejó registro
- [ ] Adjunto (imagen y documento) llega y se abre
- [ ] Reiniciar el container → reconecta **sin** pedir QR
- [ ] El vigía está activo (revisa el log tras 30 min sin tráfico)
- [ ] El dueño ve el dispositivo con el nombre correcto en *Dispositivos vinculados*
- [ ] Alguien recibe una alerta si la sesión se cierra (`loggedOut`)

---

## 10. El riesgo, dicho claro

Baileys es una vía **no oficial** y va contra los términos de Meta. Con solo
lectura + proxy residencial + filtrado el riesgo baja mucho, pero **no es cero**.
El peor caso común es que desvinculen el dispositivo y haya que re-escanear el QR;
el peor caso real es que baneen el número del cliente.

**Eso se le dice al cliente antes, no después.** Nunca conectes el número
principal de un negocio sin que el dueño entienda y acepte ese riesgo por escrito.

---

*Escrito por el agente de Sergio (lidergorila), 14-ago-2026. Si algo acá no
cuadra con lo que ves en el código, gana el código — y avísale a Sergio para
corregir este documento.*
