Saltar al contenido
Chatbots de WhatsApp desde cero
Temario Ir al ejercicio →

El estado de la conversación

  • ⏱ 55 minutos
  • 📶 Nivel: intermedio
  • 🧩 Incluye ejercicio

💡 🎯 Al terminar esta lección podrás…

  • Explicar por qué un bot necesita memoria (estado) y qué guarda una sesión por cliente.
  • Leer y dibujar una máquina de estados con estados, eventos y transiciones.
  • Describir la firma procesar(sesion, entrada) -> { sesion, respuestas } y por qué es una función pura.
  • Manejar eventos globales (cancelar, hablar con una persona) y botones viejos sin romper el flujo.
  • Escribir una mini máquina de estados que no muta la sesión recibida.

1.Por qué un bot necesita memoria

Imagina que un cliente del Minimarket La Esquina te escribe «2» a las 10:00. ¿Qué significa? Si un minuto antes el bot preguntó «¿Cuántas leches quieres?», es una cantidad. Si preguntó «¿Cómo vas a pagar?», probablemente es un error. Si es el primer mensaje del día, no significa nada. El mismo texto vale cosas distintas según el punto de la conversación en que estás.

Ese «punto en que estás» se llama estado. Sin estado, un bot solo puede responder a cada mensaje como si fuera el primero (un bot «sin memoria»), y eso sirve para un menú fijo, pero no para tomar un pedido de varios pasos: ¿qué pidió el cliente?, ¿ya eligió entrega?, ¿ya dio su dirección?

Hay una pista importante en cómo funciona WhatsApp: cada mensaje llega a tu código por separado, como una petición independiente (lo verás en la lección 9). Tu servidor no «recuerda» al cliente entre una petición y otra. Tienes que guardar esa memoria tú, y devolverla cuando llegue el siguiente mensaje.

📌 Nota · Estado vs. mensaje

Un mensaje es lo que dice el cliente en este instante. El estado es todo lo que el bot sabe de esa conversación hasta ahora. Para decidir qué responder siempre necesitas las dos cosas.

2.La sesión: la libreta de cada cliente

La memoria del bot es una sesión: un objeto que guarda todo lo que sabemos de un cliente en una conversación. Hay una sesión por teléfono. En el código de referencia del curso, crearSesion la crea así (ejecutado en Node 24):

ejemplo.js
import { crearSesion } from './src/motor.js';

console.log(crearSesion('51999000111', 0));
Salida esperada
{
  telefono: '51999000111',
  estado: 'INICIO',
  carrito: [],
  entrega: null,
  direccion: null,
  pago: null,
  fallos: 0,
  productoPendiente: null,
  cantidadPendiente: null,
  pedidoNuevo: null,
  derivadoHumano: false,
  recordatorioEnviado: false,
  creadoEn: 0,
  ultimoMensajeCliente: 0,
  actualizadoEn: 0
}

Fíjate en cómo se agrupa lo que contiene:

Campo Para qué sirve
telefono Identifica al cliente (en el curso usamos el número falso 51999000111).
estado El punto de la conversación: INICIO, MENU, ELIGIENDO...
carrito, entrega, direccion, pago Lo que el cliente va decidiendo durante el pedido.
fallos Cuántos mensajes seguidos no entendimos (para ofrecer ayuda humana).
productoPendiente, cantidadPendiente Datos a medias: «eligió leche, falta la cantidad».
pedidoNuevo Lo llena el motor al confirmar; quien guarda datos lo recoge.
derivadoHumano Si una persona está atendiendo (el bot debe callar).
ultimoMensajeCliente, actualizadoEn, creadoEn Marcas de tiempo, en milisegundos. Sirven para la regla de las 24 horas y los recordatorios.

Las marcas de tiempo son números de la forma Date.now(), como en el ejercicio de la lección 1. El motor las recibe como parámetro (ahora) en lugar de leer el reloj él mismo; más adelante verás por qué.

3.Estados, eventos y transiciones

Una máquina de estados es una forma ordenada de pensar este problema. Tiene tres ingredientes:

Los estados del bot del minimarket están declarados en src/motor.js:

src/motor.js
export const ESTADOS = {
  INICIO: 'INICIO',
  MENU: 'MENU',
  ELIGIENDO: 'ELIGIENDO',
  CARRITO: 'CARRITO',
  TIPO_ENTREGA: 'TIPO_ENTREGA',
  DIRECCION: 'DIRECCION',
  PAGO: 'PAGO',
  CONFIRMAR: 'CONFIRMAR',
  FIN: 'FIN',
  ESPERANDO_HUMANO: 'ESPERANDO_HUMANO',
};

Y el camino normal de un pedido, tal como lo dibujaste en la lección 2, se ve así:

INICIOprimer mensaje
→hola
MENU3 botones
→hacer pedido
ELIGIENDOcategorías y productos
→finalizar
TIPO_ENTREGArecojo o delivery
→delivery
DIRECCIONtexto libre
→dirección
PAGOefectivo, Yape, transferencia
→elegir
CONFIRMARresumen
→confirmar
FINpedido guardado

Hay dos estados extra que no están en el camino principal: CARRITO (el cliente está viendo o editando lo que lleva) y ESPERANDO_HUMANO (una persona tomó la conversación). Y desde cualquier punto, «cancelar» lleva a FIN y «hablar con una persona» lleva a ESPERANDO_HUMANO.

💡 Dibuja antes de programar

Si no puedes dibujar la máquina de estados en una hoja con cajas y flechas, todavía no entiendes tu bot. Es mucho más barato corregir un dibujo que reescribir código. Si usaste el flujo de la lección 2, ya tienes este dibujo casi hecho.

4.Mirar al motor cambiar de estado

Veámoslo funcionar. Creamos una sesión, le mandamos «hola», y luego tres toques de botón (cada toque llega como un id; cómo se envían los botones lo verás en la lección 14):

ejemplo.js
import { crearSesion, procesar } from './src/motor.js';

const s0 = crearSesion('51999000111', 0);
let r = procesar(s0, { tipo: 'texto', texto: 'hola' }, 1000);
console.log(r.sesion.estado, '| la original sigue en', s0.estado);
console.log(r.respuestas.map((x) => x.tipo));

let s = r.sesion;
for (const id of ['menu_pedir', 'cat:lacteos', 'prod:leche']) {
  const q = procesar(s, { tipo: 'interactivo', id }, 2000);
  s = q.sesion;
  console.log(id, '->', s.estado, '| producto pendiente:', s.productoPendiente, '|', q.respuestas[0].tipo);
}
Salida esperada
MENU | la original sigue en INICIO
[ 'texto', 'botones' ]
menu_pedir -> ELIGIENDO | producto pendiente: null | lista
cat:lacteos -> ELIGIENDO | producto pendiente: null | lista
prod:leche -> ELIGIENDO | producto pendiente: leche | botones

Observa tres cosas:

  1. Después de «hola» la sesión pasó de INICIO a MENU y el bot respondió con dos mensajes (bienvenida + botones). Una respuesta puede ser una lista de mensajes.
  2. El estado no cambia siempre: elegir una categoría y luego un producto sigue siendo ELIGIENDO. Lo que cambia es el dato productoPendiente («elegiste leche, falta cuántas»). Un estado puede tener sub-pasos guardados en la sesión.
  3. La sesión original s0 no cambió: sigue en INICIO. Ahora vemos por qué.

5.El cerebro es una función pura

La firma de todo el motor es esta:

Texto
procesar(sesion, entrada, ahora) -> { sesion, respuestas }

Recibe la sesión actual, lo que escribió el cliente y la hora; devuelve la sesión nueva y la lista de respuestas que hay que enviar. No envía nada por WhatsApp, no guarda archivos, no consulta una base de datos, no lee el reloj. Eso la hace una función pura: con las mismas entradas siempre devuelve lo mismo y no cambia nada fuera de sí misma.

Las primeras líneas de procesar en src/motor.js muestran cómo se logra:

src/motor.js
export function procesar(sesionPrevia, entrada, ahora = Date.now()) {
  const sesion = structuredClone(sesionPrevia); // nunca modificamos la sesión original
  sesion.pedidoNuevo = null;
  sesion.ultimoMensajeCliente = ahora;
  sesion.actualizadoEn = ahora;
  sesion.recordatorioEnviado = false;
  // ...
}

structuredClone hace una copia profunda: el motor trabaja sobre la copia y devuelve la copia. Quien lo llame decide qué hacer con ella.

¿Por qué tanto empeño? Por cuatro ventajas muy concretas:

Que ahora sea un parámetro y no un Date.now() escondido adentro también ayuda: en las pruebas y los ejemplos de esta lección pasamos 0, 1000, 2000, y el resultado es siempre el mismo.

⚠️ Importante · Sin entrada/salida dentro del motor

Regla de oro del curso: el motor decide, la carcasa ejecuta. El motor devuelve «responde esto» y la carcasa lo envía. Si un día pones un fetch o un console.log dentro de la lógica del flujo, habrás perdido todas las ventajas de arriba.

6.Entender primero, actuar después

Dentro de procesar el trabajo está dividido en dos fases, y el código lo dice con comentarios:

Texto
1) Entender: convertir la entrada en una "acción"
2) Actuar: cambiar la sesión y preparar respuestas

En la fase 1, un toque de botón ({ tipo: 'interactivo', id: 'cant:2' }) y un texto libre («quiero 2 leches») se convierten en un mismo vocabulario de acciones: pedir, categoria, producto, cantidad, entrega, pago, cancelar... En la fase 2, una función ejecutar mira la acción y la sesión, y decide.

src/motor.js
function accionDeId(id) {
  const [clave, valor] = String(id).split(':');
  const simples = {
    menu_pedir: 'pedir', seguir: 'pedir', menu_menu: 'menu', menu_horario: 'horario',
    menu_humano: 'humano', ver_carrito: 'carrito', cerrar: 'cerrar', vaciar: 'vaciar',
    pedido_confirmar: 'confirmarPedido', pedido_editar: 'editar', pedido_cancelar: 'cancelar',
  };
  if (simples[clave]) return { tipo: simples[clave] };
  if (clave === 'cant' && Number.isInteger(Number(valor))) return { tipo: 'cantidad', cantidad: Number(valor) };
  // ... (entrega, pago, categoría, producto)
  return { tipo: 'desconocida' };
}

Los ids de los botones tienen una convención: clave:valor (cant:2, pago:yape, entrega:delivery). Así un solo split(':') los descompone. Esta separación en dos fases tiene un premio enorme: el día que agregues IA para entender texto (lección 22), solo cambias la fase 1; la fase 2 queda intacta.

7.El mismo mensaje, distinto estado

Aquí se ve el valor del estado. Cuando el cliente escribe «tres»:

ejemplo.js
// Con 'leche' pendiente de cantidad (estado ELIGIENDO):
const q = procesar(s, { tipo: 'texto', texto: 'tres' }, 3000);
console.log(q.sesion.carrito);
console.log(q.respuestas[0].texto);
Salida esperada
[
  { id: 'leche', nombre: 'Leche entera 1 L', precio: 4.3, cantidad: 3 }
]
Agregué 3 x Leche entera 1 L.
Llevas 3 producto(s). Subtotal: S/ 12.90.

Esto funciona porque en accionDeTexto hay una regla que solo existe en ese contexto:

src/motor.js
if (sesion.estado === E.ELIGIENDO && sesion.productoPendiente) {
  const n = cantidadSola(textoCliente);
  if (n !== null) return { tipo: 'cantidad', cantidad: n };
}

Si el motor no tuviera productoPendiente, «tres» se interpretaría como un mensaje sin sentido. Del mismo modo, en el estado TIPO_ENTREGA la palabra «delivery» significa «quiero delivery», y en el estado DIRECCION todo lo que escribe el cliente es la dirección (incluso un «hola»). Es la misma lección una y otra vez: qué significa un mensaje lo decide el estado.

8.Botones viejos y eventos fuera de lugar

En WhatsApp los botones de un mensaje antiguo siguen en el historial del chat. El cliente puede tocar «Confirmar pedido» una hora después, cuando el pedido ya terminó, o tocar «Yape» sin haber elegido nada. Un bot ingenuo se rompe. El motor de referencia defiende cada paso con una tabla:

src/motor.js
// Acciones que solo tienen sentido en un paso concreto (un botón viejo no debe romper el flujo).
const SOLO_EN = {
  entrega: E.TIPO_ENTREGA,
  pago: E.PAGO,
  confirmarPedido: E.CONFIRMAR,
};

Si la acción no corresponde al estado actual, el bot avisa y repite la pregunta del paso en que sí estás. Lo comprobamos en una sesión recién abierta (en MENU), tocando un botón de confirmación:

ejemplo.js
let t = procesar(crearSesion('51999000111', 0), { tipo: 'texto', texto: 'hola' }, 1).sesion;
const g = procesar(t, { tipo: 'interactivo', id: 'pedido_confirmar' }, 2);
console.log(g.sesion.estado, g.respuestas.map((x) => x.texto));
Salida esperada
MENU [
  'Esa opción ya no está disponible en este paso.',
  '¿Qué deseas hacer?'
]

El estado no cambia, el cliente recibe una explicación corta y vuelve a ver el menú. Esa función se llama repetirPaso: un switch sobre el estado que vuelve a mostrar la pregunta pendiente. Es una de las piezas que hacen que un bot se sienta «sólido».

9.Eventos globales: cancelar y hablar con una persona

Algunas intenciones valen en cualquier estado y tienen prioridad sobre todo lo demás: cancelar y hablar con una persona. En accionDeTexto se revisan antes que nada (incluso antes de tratar el texto como dirección):

Texto
if (intencion === 'cancelar' || intencion === 'humano') return { tipo: intencion };

Cancelar reinicia la sesión y la deja en FIN; el siguiente mensaje empieza una conversación nueva. Probado:

ejemplo.js
let c = procesar(t, { tipo: 'texto', texto: '2 leches' }, 8).sesion;
console.log('carrito antes:', c.carrito.length);
c = procesar(c, { tipo: 'texto', texto: 'cancelar' }, 9);
console.log(c.sesion.estado, c.sesion.carrito.length, '|', c.respuestas[0].texto);
c = procesar(c.sesion, { tipo: 'texto', texto: 'hola' }, 10);
console.log(c.sesion.estado);
Salida esperada
carrito antes: 1
FIN 0 | Cancelé tu pedido. Cuando quieras empezar de nuevo, escribe "hola".
MENU

«Hablar con una persona» es más sutil porque hay que callar. Si una persona del negocio ya está contestando en el mismo chat, un bot que sigue respondiendo es un desastre. El estado ESPERANDO_HUMANO resuelve eso; en procesar se ve la regla:

src/motor.js
// Con una persona atendiendo, el bot calla (salvo "menu" o "cancelar").
const quiereSalir = accion.tipo === 'cancelar' || (accion.tipo === 'menu' && !accion.saludo);
if (sesion.estado === E.ESPERANDO_HUMANO && !quiereSalir) {
  return { sesion, respuestas: [] };
}
ejemplo.js
let h = procesar(t, { tipo: 'texto', texto: 'quiero hablar con una persona' }, 5);
console.log(h.sesion.estado, h.sesion.derivadoHumano);
h = procesar(h.sesion, { tipo: 'texto', texto: 'hola?? alguien' }, 6);
console.log('respuestas mientras atiende una persona:', h.respuestas.length);
h = procesar(h.sesion, { tipo: 'texto', texto: 'menu' }, 7);
console.log(h.sesion.estado);
Salida esperada
ESPERANDO_HUMANO true
respuestas mientras atiende una persona: 0
MENU

Las respuestas son una lista vacía: callar también es una respuesta válida. Y siempre hay una salida (menu o cancelar) para que el cliente no quede atrapado. Cómo se organiza la atención humana de verdad lo verás en la lección 25.

10.Cuando no entiendes: el contador de fallos

El campo fallos evita un clásico: el bot que responde «No te entendí» eternamente. La regla del código de referencia es simple. Primer mensaje incomprensible: «No te entendí bien» y se repite la pregunta del paso. Segundo seguido: se ofrece hablar con una persona. Si el cliente dice algo que sí se entiende, fallos vuelve a 0.

ejemplo.js
let f = procesar(t, { tipo: 'texto', texto: 'asdf' }, 3);
console.log(f.sesion.fallos, f.respuestas.map((x) => x.tipo));
f = procesar(f.sesion, { tipo: 'texto', texto: 'qwer' }, 4);
console.log(f.sesion.fallos, f.respuestas[0].texto);
console.log(f.respuestas[0].botones);
Salida esperada
1 [ 'texto', 'botones' ]
2 Sigo sin entenderte. ¿Quieres que te atienda una persona?
[
  { id: 'menu_humano', titulo: 'Hablar con alguien' },
  { id: 'menu_menu', titulo: 'Ver el menú' }
]

Este patrón («reintenta, y luego ofrece una salida») es parte del buen diseño que viste en la lección 2. Un detalle más: si el cliente envía algo que el motor no soporta (una foto, un audio), llega como { tipo: 'no_soportado' } y el bot responde «Por ahora solo entiendo texto y botones», sin tocar el estado.

11.Dónde vive la sesión

El motor no guarda la sesión: la recibe y la devuelve. Alguien más debe guardarla entre un mensaje y el siguiente, y eso depende de la carcasa:

Carcasa Dónde vive la sesión
Consola (lección 4) En una variable: let sesion = crearSesion(...). Se pierde al cerrar.
Servidor del bot (lecciones 10 y 15) En Cloudflare KV, con la clave sesion:<telefono>; sobrevive a los reinicios y vence solo.

Así lo hace consola.js, el simulador de la terminal, en tres líneas:

consola.js
let sesion = crearSesion(TELEFONO);
// ...por cada línea que escribes:
const resultado = procesar(sesion, aEntrada(texto));
sesion = resultado.sesion;

⚠️ Importante · Verifica este dato

Cuando guardes sesiones en la nube, revisa los límites y el modelo de consistencia del almacenamiento que elijas (en la lección 15 usarás Cloudflare KV). Dos mensajes simultáneos del mismo cliente pueden pisarse. Consulta la documentación oficial de Cloudflare KV antes de decidir.

12.Errores frecuentes

13.Apuntes para llevar

14.Glosario

Término Significado
Estado Punto de la conversación en que está un cliente (MENU, PAGO...).
Sesión Objeto que guarda todo lo que el bot sabe de un cliente.
Máquina de estados Modelo con estados, eventos y transiciones entre ellos.
Transición Paso de un estado a otro provocado por un evento.
Función pura Función que con las mismas entradas da la misma salida y no cambia nada fuera de ella.
Mutar Modificar un objeto existente en lugar de crear uno nuevo.
Acción Intención ya interpretada (pedir, cantidad, cancelar) que el motor ejecuta.
Carcasa Capa que conecta el motor con el mundo (consola, servidor): guarda sesiones y envía respuestas.

🧠 Comprueba lo aprendido

Elige una respuesta en cada pregunta. Verás al instante si acertaste y por qué.

Un cliente escribe «2». ¿Qué decide su significado?

¿Qué devuelve procesar(sesion, entrada, ahora)?

¿Cuál es la principal ventaja de que el motor sea una función pura?

Un cliente toca el botón «Confirmar pedido» de un mensaje viejo cuando está en el menú. ¿Qué debe hacer el bot?

¿Qué debe hacer el bot cuando el estado es ESPERANDO_HUMANO?

📚 Para profundizar (fuentes oficiales)

Este material lo hace un estudiante con ayuda de IA: ante cualquier duda, manda la documentación oficial.

Tu progreso se guarda en este navegador y se refleja en el índice del curso.

💡 🚀 ¡Ahora te toca!

La práctica fija lo aprendido. Pasa al ejercicio de esta lección.

Ir al ejercicio