Arncof
Artículos

WhatsApp con IA: cómo agrupar mensajes antes de responder (parte 1)

Cómo agrupar los mensajes que llegan en ráfaga a un bot de WhatsApp con IA: ventana por persona, un turno a la vez, tope y bandeja durable.

Operación de agentes · · · 7 min de lectura · Read in English
Ilustración abstracta de varias burbujas de chat que se agrupan en una sola
En este artículo

Respuesta corta: para que un asistente de IA en WhatsApp no conteste cada mensaje suelto, hay que esperar unos segundos por persona (por defecto uso 20), juntar todo lo que llega en esa ventana y responder una sola vez. Nunca debe haber dos turnos de IA a la vez para la misma persona, y tiene que haber un tope para que alguien que no deja de escribir igual reciba respuesta. Además, los mensajes se guardan en una base de datos antes de confirmar el webhook, para que un reinicio no los pierda. Esta es la parte 1 de una serie sobre WhatsApp con IA en producción.

¿Por qué agrupar los mensajes?

Porque la gente no escribe en párrafos. Escribe en ráfagas:

Hola

una consulta

¿tienen turno para el sábado?

a la tarde

Si el asistente responde al primer "Hola", ya llega tarde a la pregunta real. Si responde a cada línea, el cliente recibe cuatro respuestas que se pisan, la IA gasta cuatro veces más y la conversación se siente robótica. Agrupar resuelve las tres cosas: una respuesta coherente, menos costo y un ritmo más humano.

¿Cómo funciona la ventana por persona?

La idea es un debounce por conversación. La clave es la combinación de canal y remitente, de modo que cada persona tiene su propia ventana.

  • Llega un mensaje: se abre (o se reinicia) un temporizador de N segundos para esa persona.
  • Llega otro antes de que venza: se suma al grupo y el temporizador vuelve a empezar.
  • Vence el temporizador: se envía el grupo completo a la IA como un único turno.

Mi valor por defecto es 20 segundos, configurable entre 1 y 300.

type Msg = { from: string; channel: string; text: string; at: number };

const WINDOW_MS = 20_000;
const pending = new Map<string, { msgs: Msg[]; timer?: NodeJS.Timeout }>();

function onMessage(msg: Msg, flush: (key: string, msgs: Msg[]) => void) {
  const key = `${msg.channel}:${msg.from}`;
  const entry = pending.get(key) ?? { msgs: [] };
  entry.msgs.push(msg);
  if (entry.timer) clearTimeout(entry.timer);
  entry.timer = setTimeout(() => {
    pending.delete(key);
    flush(key, entry.msgs);
  }, WINDOW_MS);
  pending.set(key, entry);
}

Este primer ejemplo es didáctico. Más abajo explico por qué guardar todo en memoria fue un error en producción.

¿Qué pasa si la persona escribe mientras la IA está respondiendo?

Regla de oro: nunca dos turnos a la vez para la misma persona. Si la IA está generando una respuesta y llega un mensaje nuevo, ese mensaje no dispara un segundo turno en paralelo. Queda en cola y forma parte del siguiente grupo cuando termina el turno actual.

Sin esta regla aparecen respuestas cruzadas: la IA contesta dos versiones distintas de la misma pregunta, o responde algo que el cliente ya corrigió.

¿Cómo evitar que alguien que no para de escribir nunca reciba respuesta?

Con un tope. Si cada mensaje reinicia la ventana, una persona que escribe sin parar puede posponer la respuesta para siempre. Por eso la espera total desde el primer mensaje del grupo tiene un máximo (en mi camino durable, 60 segundos). Pasado ese límite, el grupo se procesa aunque sigan llegando mensajes, y lo nuevo va al grupo siguiente.

const MAX_WAIT_MS = 60_000;

function dueAt(firstAt: number, lastAt: number): number {
  // Se espera la ventana desde el último mensaje, pero nunca más del tope
  // contado desde el primero.
  return Math.min(lastAt + WINDOW_MS, firstAt + MAX_WAIT_MS);
}

¿Por qué hay que guardar los mensajes antes de confirmar el webhook?

Por un incidente real. La primera versión hacía lo del ejemplo de arriba: un mapa en memoria y un temporizador. El servidor respondía 200 a Meta en cuanto llegaba el mensaje. Un día hubo un reinicio dentro de la ventana de espera, y los mensajes que estaban en memoria se perdieron. Meta ya tenía su 200, así que no los iba a reenviar. Para el cliente, el asistente simplemente lo ignoró.

La documentación oficial de webhooks de WhatsApp aclara que, si tu servidor no responde 200, Meta reintenta la entrega durante hasta 7 días con frecuencia decreciente, y que eso puede generar duplicados. O sea: el 200 es tu promesa de que ya te hiciste cargo del mensaje. Si lo prometes antes de guardarlo, cualquier reinicio rompe esa promesa.

Automatización, una vez por semana

El newsletter semanal cuenta lo que pruebo al automatizar un flujo y recuerda la tabla de costo de lista (USD de lista, no lo facturado). El código, si lo hay, está en el artículo.

Si ves una verificación o un error de Buttondown, complétalo ahí.

Te suscribes al newsletter de Yoseph Franco (vía Buttondown). Puedes darte de baja cuando quieras.

Diagrama abstracto: mensajes en ráfaga, una ventana que los agrupa, una base de datos con candado y una única respuesta

La solución fue una bandeja de entrada durable:

  1. Guardar primero, confirmar después. El webhook escribe el mensaje en la base de datos y recién entonces responde 200.
  2. Deduplicar. Como Meta puede reenviar, cada mensaje tiene una clave única por canal e identificador externo, además de un hash del contenido.
  3. Bloqueo por persona. Un candado transaccional por conversación garantiza que un solo proceso trabaje cada grupo.
  4. Respetar el orden. Si un grupo anterior de esa persona todavía está pendiente, el nuevo espera.
  5. Arriendos (leases).* Quien toma un grupo lo "alquila" por 120 segundos y renueva el arriendo cada 30 mientras trabaja. Si el proceso muere, el arriendo vence y otro lo retoma.
  6. Reintentos con backoff.* Hasta 4 intentos, con una espera que se duplica cada vez (5 s, 10 s, 20 s…). Después, revisión manual.
  7. Revisión manual en vez de una respuesta tardía. Si un grupo tiene más de 15 minutos, no se contesta automáticamente: pasa a revisión. Una respuesta de IA que llega un cuarto de hora tarde, fuera de contexto, es peor que una persona revisándola.
async function claimNextBatch(db: Db, workerId: string) {
  return db.transaction(async (tx) => {
    const batch = await tx.findDueBatch({ now: Date.now() });
    if (!batch) return null;
    await tx.lockConversation(batch.conversationKey); // candado por persona
    if (await tx.hasOlderPendingBatch(batch)) return null; // mantener orden
    if (Date.now() - batch.firstAt > 15 * 60_000) {
      await tx.markForReview(batch.id, "too_old"); // mejor revisión que tarde
      return null;
    }
    await tx.lease(batch.id, workerId, 120_000);
    return batch;
  });
}

const retryDelayMs = (attempt: number) => 5_000 * 2 ** attempt;

Dos detalles más que aprendí a la fuerza. Primero, limitar la concurrencia: proceso como máximo 5 grupos a la vez y reviso la cola cada segundo. Segundo, en un apagado ordenado espero hasta 10 segundos, y nunca libero un arriendo mientras la IA todavía está ejecutando herramientas. Si lo libero, otro proceso puede tomar el mismo grupo y responder dos veces.

¿Funciona en producción?

Sí. En una ventana de medición reciente con el camino durable activo, entraron 273 mensajes y salieron 354 partes de respuesta, todas con confirmación de entrega de Meta. Es un dato puntual de un periodo, no un benchmark, pero es la prueba que me importaba: lo que el sistema envió, llegó.

¿Qué viene en las partes 2 y 3?

  • Parte 2: indicador de "escribiendo" y marcas de leído. Cómo marcar el mensaje como leído y mostrar el indicador sin mentirle al usuario. Según la documentación de Meta, el indicador se descarta cuando respondes o a los 25 segundos, y solo debería mostrarse si de verdad vas a responder.
  • Parte 3: entrega, reintentos y la ventana de 24 horas. Cómo reintentar envíos sin duplicar mensajes y por qué hay que volver a verificar la ventana de servicio al cliente antes de cada envío: fuera de esas 24 horas, solo puedes enviar plantillas.

Preguntas frecuentes

¿Cuántos segundos debe durar la ventana? Uso 20 por defecto. Busca el equilibrio entre no cortar ráfagas y no hacer esperar de más, y mídelo con tus propios usuarios.

¿No alcanza con guardar en memoria? Para un prototipo, sí. En producción, no: cualquier reinicio dentro de la ventana pierde mensajes que Meta ya considera entregados.

¿Qué pasa con los mensajes duplicados? Meta puede reenviar un webhook. Por eso cada mensaje se guarda con una clave única y los duplicados se descartan.

¿Por qué mandar a revisión en vez de contestar tarde? Porque una respuesta automática fuera de contexto daña más la conversación que una demora atendida por una persona.

Fuentes

Una pregunta más

¿El correo trae el código de la automatización?

No. El artículo está en el sitio. El newsletter semanal cuenta lo que se prueba y recuerda la tabla de costo de lista (USD de lista, no lo facturado).

Automatización, una vez por semana

El newsletter semanal cuenta lo que pruebo al automatizar un flujo y recuerda la tabla de costo de lista (USD de lista, no lo facturado). El código, si lo hay, está en el artículo.

Si ves una verificación o un error de Buttondown, complétalo ahí.

Te suscribes al newsletter de Yoseph Franco (vía Buttondown). Puedes darte de baja cuando quieras.