Cómo Hicimos Nuestro Bridge de WhatsApp Indestructible

IngenieríaWhatsAppBaileysWebSockets6 min de lectura
Cómo Hicimos Nuestro Bridge de WhatsApp Indestructible

Cómo Hicimos Nuestro Bridge de WhatsApp Indestructible

Ghosty es nuestro asistente interno. Vive en el dashboard de Formmy, pero también en WhatsApp — donde monitorea grupos, responde preguntas y ejecuta herramientas administrativas. Para la conexión con WhatsApp usamos Baileys, la librería open-source que implementa el protocolo de WhatsApp Web.

El problema: Ghosty se conectaba, listaba los grupos, y 30 segundos después perdía la conexión. Le dabas otro código de pairing, volvía a listar los grupos, y después no podía ni enviar mensajes ni ejecutar herramientas. A veces se quedaba en un loop de reconexión infinito mostrando códigos de pairing que nadie pedía.

El Diagnóstico

Baileys usa WebSockets contra los servidores de WhatsApp. Las desconexiones son normales — el protocolo las espera. Un cambio de red, un timeout de inactividad, un stream error 515 después del pairing inicial. La conexión se va a caer. La pregunta no es si se cae, sino qué pasa cuando se cae.

Nuestro bridge original tenía tres problemas fundamentales:

1. Sockets fantasma

Cada vez que connect() se llamaba (incluyendo reconexiones), se creaba un nuevo socket sin cerrar el anterior. Los event listeners del socket viejo seguían activos. El resultado: eventos connection.update duplicados, doble listado de grupos, y un estado corrupto donde this.sock apuntaba al socket nuevo pero los listeners del viejo seguían disparando callbacks.

typescript
// Antes: socket nuevo sin limpiar el anterior
async connect() {
  const sock = makeWASocket({ ... });
  this.sock = sock; // El socket anterior sigue vivo con sus listeners
  sock.ev.on("connection.update", ...); // Ahora hay DOS listeners
}

2. Reconexión con amnesia

Cuando Baileys se desconectaba, nuestro método connectInternal() intentaba reconectar pero olvidaba el número de teléfono. Si las credenciales no se habían guardado completamente durante el pairing inicial, la reconexión intentaba autenticarse sin pairing code y sin QR — quedando en limbo.

typescript
// Antes: reconexión sin contexto
private async connectInternal() {
  return this.connect({ onQR: this.qrCallback }); // phoneNumber? perdido
}

3. Límite de intentos arbitrario

Teníamos un maxReconnectAttempts = 5. Después del quinto intento, Ghosty simplemente se rendía. En una conexión de WhatsApp que corre 24/7, cinco intentos son nada. Un blip de red de 2 minutos agotaba los intentos y el bridge quedaba muerto hasta el próximo deploy.

La Solución: Copiar de Ti Mismo

Tenemos otro proyecto — NanoClaw — que usa el mismo stack de Baileys pero lleva meses en producción sin problemas de reconexión. La diferencia estaba en cuatro patrones que nunca portamos a Ghosty.

Patrón 1: Cleanup antes de reconectar

Antes de crear un socket nuevo, matar al anterior completamente:

typescript
async connect() {
  // Limpiar socket anterior
  if (this.sock) {
    try {
      this.sock.ev.removeAllListeners("connection.update");
      this.sock.ev.removeAllListeners("messages.upsert");
      this.sock.ev.removeAllListeners("creds.update");
      this.sock.end(undefined);
    } catch {}
    this.sock = null;
  }

  const sock = makeWASocket({ ... });
  this.sock = sock;
  // Ahora sí, un solo set de listeners
}

Patrón 2: Reconexión infinita con backoff

Sin límite de intentos. Backoff exponencial desde 5 segundos hasta 5 minutos máximo. La única razón para no reconectar es un logout explícito (el usuario desvinculó el dispositivo desde WhatsApp).

typescript
private scheduleReconnect(attempt: number): void {
  const delayMs = Math.min(5000 * Math.pow(2, attempt - 1), 300000);
  setTimeout(() => {
    this.connect({
      onQR: this.qrCallback || undefined,
      phoneNumber: this.phoneNumber, // Preservar contexto
    }).catch(() => {
      this.scheduleReconnect(attempt + 1); // Nunca rendirse
    });
  }, delayMs);
}

La secuencia queda: 5s, 10s, 20s, 40s, 80s, 160s, 300s, 300s, 300s... Agresivo al inicio, paciente después.

Patrón 3: Cola de mensajes salientes

El cambio más impactante. En vez de tirar un error cuando el bridge está desconectado, encolar el mensaje y enviarlo cuando la conexión regrese:

typescript
async sendMessage(jid: string, text: string): Promise<void> {
  if (!this.sock || this.status !== "connected") {
    this.outgoingQueue.push({ jid, text });
    return; // No throw, no error — el mensaje se enviará después
  }
  try {
    await this.sock.sendMessage(jid, { text });
  } catch (err) {
    // Fallo de envío → encolar para retry
    this.outgoingQueue.push({ jid, text });
  }
}

Y al reconectar:

typescript
if (connection === "open") {
  this.status = "connected";
  // Enviar mensajes pendientes
  this.flushOutgoingQueue();
}

Esto cambia la semántica del bridge de "si no hay conexión, fallo" a "si no hay conexión, espero". Para un servicio 24/7 la diferencia es enorme — un blip de red de 30 segundos antes mataba mensajes, ahora simplemente los retrasa.

Patrón 4: Presence update al conectar

Un detalle sutil: al conectar, enviar sendPresenceUpdate("available"). Sin esto, WhatsApp no envía updates de presencia (typing indicators) de otros participantes, y en algunos casos la conexión se marca como inactiva más rápido.

typescript
if (connection === "open") {
  sock.sendPresenceUpdate("available");
}

El Resultado

MétricaAntesDespués
Reconexiones exitosas~60%~100%
Mensajes perdidos por desconexiónTodosCero (encolados)
Max downtime por blip de redPermanente (5 intentos)~5 min (backoff)
Sockets fantasmaAcumulativosCero

Lecciones

Los WebSockets se caen. Siempre. El protocolo de WhatsApp Web es especialmente volátil — cambios de IP, timeouts de sesión, stream errors post-pairing. Tu código de reconexión es más importante que tu código de conexión.

Cleanup primero, connect después. Si no limpias los listeners del socket anterior, cada reconexión duplica los eventos. El síntoma es sutil: todo parece funcionar pero los mensajes llegan doble, los estados se corrompen, y eventualmente algo explota.

No tires errores por estado transitorio. Un bridge desconectado no es un error — es un estado temporal. Encolar y reintentar es casi siempre mejor que fallar ruidosamente. El usuario de WhatsApp no sabe ni le importa que hubo una desconexión de 10 segundos entre su mensaje y la respuesta.

Roba de tu propio código. Teníamos la solución en otro repo desde hace meses. A veces el mejor fix es admitir que ya lo resolviste en otro lado y portarlo.


Prueba Formmy

Si estás construyendo agentes de IA que necesitan estar disponibles 24/7 en WhatsApp — con reconexión automática, cola de mensajes, y cero mensajes perdidos — Formmy te da todo esto listo para usar.

Prueba Formmy Gratis - Agentes de IA que nunca se desconectan.

¿Preguntas sobre integraciones de WhatsApp? Nuestro equipo está listo para ayudarte.