Saltar al contenido principal

Flujo básico

Cuando alguien escribe “hola”, el bot pide el nombre, espera la respuesta, la guarda en c.state.name y lo saluda. flow() acepta cualquier trigger: un texto, una lista, una regex, un predicado, o WELCOME, IMAGE y los demás triggers especiales.

Registrar e iniciar flujos

El id de un flujo es su trigger de texto, normalizado (flow('Hola') es 'hola'; una lista usa su primer texto). Un flujo construido con una regex, un predicado o un trigger especial necesita .id('nombre') para iniciarlo por nombre, usarlo como destino de goto o hacerlo .persistent(). Mientras un usuario está dentro de un flujo, sus mensajes van al flujo, no a otros triggers. Los comandos siguen ganando, así que /reset (con slashCommandsPlugin) siempre saca al usuario.

Métodos del DSL

say — enviar un mensaje

{ delay } ('500ms', '2s', '1m' o milisegundos) también funciona en ask, choose (cuarto argumento, después de la clave de estado: .choose(prompt, opciones, 'choice', { delay: '1s' })) y media. Es una pausa antes del paso, independiente del indicador de escritura que BotConfig.typing añade a cada .say. Para pausar entre pasos sin enviar nada, usa .delay('3s'). Un delay inválido, o mayor de lo que admite un timer de Node (2^31 − 1 ms, unos 24 días), lanza un error al construir el flow. Terminar la conversación (/reset, un reemplazo, bot.stop()) cancela el delay pendiente.

ask — enviar un mensaje y esperar la respuesta

Si validate devuelve un texto de error, el bot lo envía y vuelve a preguntar. Las opciones son las mismas en todas las esperas (.ask(), .choose(), y c.ask(), c.waitText(), c.choose()… en conversaciones): Cuando se rechaza el último intento, la conversación termina con retriesMessage (”❌ Demasiados intentos. Conversación terminada.” con lang: 'es'; ”❌ Too many attempts. Conversation ended.” con el idioma por defecto, inglés).

Validación con schema (compatible con zod)

schema acepta cualquier objeto con un método safeParse(): zod, adaptadores de valibot o tu propio validador. El core no depende de zod. En c.state se guarda el valor parseado, y una respuesta rechazada vuelve a preguntar con otherwise o, si no está definido, con el mensaje del primer issue del schema (o con el mensaje integrado y traducible core.wait.invalid si el schema no da ninguno, ver i18n):
schema corre después de validate y comparte su presupuesto de maxAttempts. También funciona en conversaciones: await c.ask(q, { schema }) y await c.waitText({ schema }) resuelven con el valor parseado.

do — ejecutar una acción, sin mensaje

media — enviar media

kind es image, video, audio, voice, document, sticker o location. La fuente puede ser una URL, una ruta, un Buffer o una función (ctx, c) => fuente calculada en tiempo de ejecución. El cuarto argumento acepta delay y opciones por tipo (filename, ptv, gifPlayback, pack, author).

choose — branching (menú numerado)

choose envía un menú numerado construido a partir de options (Record<string, string>, key → label) y guarda la key elegida en c.state.choice — o en la key que le pases como tercer argumento: .choose(prompt, options, 'answer'). Cada opción puede en cambio llevar un sub-flow then que se ejecuta justo después de elegirla; el control vuelve al flow padre después:
then también puede ser un flow completo (se ejecuta en línea y vuelve, como .use() más abajo). Una opción con goto sale del flow actual hacia otra conversación; así se construye una opción de “volver al menú principal”:

timeout — expiración por inactividad

.timeout() solo define la duración de espera — no recibe un argumento de mensaje. El texto que se envía cuando expira se configura una sola vez, de forma global, en el bot:
Un sub-flujo then: usa su propio .timeout(); no hereda el del flujo padre. Si no tiene, se aplica el conversation.timeout del bot (o el valor por defecto de 5 minutos).

goto — saltar a otro flow

El destino también tiene que estar registrado (bot.use(flow('b')...) o bot.conversation('b', ...)); bot.lint() avisa de un goto a un flujo inexistente con goto-target-missing.

use — ejecutar un sub-flow y volver

.use(subFlow) ejecuta otro flow en línea y luego sigue con el paso siguiente. El sub-flow comparte c.state, así que lo que captura se ve en el padre (y con tipos, ver Estado tipado). El sub-flow se copia al llamar a .use(); los cambios posteriores a su builder no afectan al padre. Un c.retry() en un paso del padre después de .use() vuelve a ejecutar el sub-flow cuando su última espera estaba ahí.

guard — detener si no se cumple una condición

Si el predicado (síncrono o async) devuelve un valor falsy, el flow se detiene: con un mensaje (un string o (ctx, c) => string) lo envía y termina la conversación; sin mensaje termina en silencio; con { redirect, message? } envía el mensaje (si lo hay) y salta a otra conversación como goto (también acepta un FlowBuilder como destino). bot.lint() revisa los destinos de redirect igual que los de goto.

persistent — sobrevive reinicios

.persistent() hace un checkpoint al empezar el flow y después de cada paso, así que un reinicio mientras espera, incluso en su primer .ask(), lo retoma ahí (la pregunta pendiente se vuelve a hacer). Eso solo sobrevive a un reinicio del proceso si el store tiene un archivo detrás:
Un flow necesita un id estable para poder hacer checkpoint. Un trigger de tipo string ya provee uno; si construiste el flow con un trigger de regex/predicado, dale un id explícito con .id('registrar').

scope — restringir a dm / group / all

form — varios campos tipados en un paso

.form(definicion, opts) recoge campos de texto, número, email, fecha, selección, selección múltiple y opt-in con validación. En la Cloud API de WhatsApp se envía como un WhatsApp Flow nativo; en el resto, el bot pregunta campo por campo. Más en Formularios (WhatsApp Flows).

c.state — memoria entre pasos

c.state es un objeto plano y mutable, con alcance al flow en ejecución. .ask y .choose escriben en él automáticamente; .do y la forma función de .say lo leen y escriben directamente:

Estado tipado

.ask(), .choose() y .use() añaden al tipo del flow las keys que capturan, así que c.state queda tipado sin casts. Con schema, la key toma el tipo de salida del schema; sin él es string, y .choose() da la unión de las keys de las opciones. Declara de antemano el estado que el flow espera con flow<S>(), y obtén el tipo de un builder con FlowStateOf<typeof f>:
Una key que no es un literal de string no se puede seguir; esos valores quedan accesibles como unknown.

Ejemplo completo: registro

Más allá del DSL

Para control de flujo arbitrario (bucles, ramas propias, llamar a una API externa a mitad de la conversación) escribe la conversación como una función async. Los flujos construidos con flow() compilan exactamente a este runtime.
Sin { trigger }, iníciala con bot.trigger('quiz', { chatId }) o con c.goto('quiz') desde otra conversación. El objeto c: opts acepta las opciones de espera (timeout, validate, maxAttempts, otherwise, filter, schema).
No captures las señales de controlc.goto(), c.end(), un timeout y el exceso de intentos funcionan lanzando un ConversationError. Si envuelves esperas en try/catch, relánzalo: if (e instanceof ConversationError) throw e.

ctx en bot.conversation() es un MessageContext completo

El segundo argumento de un handler de bot.conversation() es el MessageContext completo, tipado a través de DefaultTools: ctx.tools (incluidas augmentations como ctx.tools.ai o ctx.tools.nlu), ctx.user, ctx.handoff(), ctx.photo, ctx.poll, ctx.react, ctx.quote, ctx.senderInfo… Sin casts.
El tipo del handler es ConversationHandler<TTools = DefaultTools>. En el DSL (flow().do((ctx, c) => ...)) ctx sigue siendo un RuntimeMessage; ahí usa userOf(ctx) y bot.handoff(ctx.from).

c.retry(): repetir la última espera

c.retry(message?) repite la espera más reciente (ask, waitText, waitMedia, waitLocation, choose, waitAny) con las mismas opciones (validate, filter, timeout, otherwise, maxAttempts). Antes de esperar reenvía la pregunta del ask o el menú del choose, o message en su lugar. En choose, message solo reemplaza el encabezado; las opciones numeradas se mantienen. waitText/waitMedia/waitLocation/waitAny no tienen pregunta, así que solo envían message si lo pasas. c.retry('') vuelve a esperar sin enviar nada. En una conversación async devuelve el valor nuevo:
Para waitMedia/waitAny, tipa el valor: await c.retry<RuntimeMessage>('Envía otra foto') (c.retry<InboundLocation>() después de waitLocation). En un paso de flujo (.do() o una acción then de .choose()) cuya última espera es de un paso anterior, c.retry() rebobina el flujo hasta ese paso: lo vuelve a preguntar (con message en lugar de la pregunta, si lo pasas), guarda la respuesta nueva en su clave de estado y vuelve a ejecutar los pasos siguientes, incluido el .do() que lo llamó. Lanza una señal de control síncrona, así que el código posterior del paso no corre (como con c.end()):
  • Funciona en sub-flujos (then: subFlow, .use(subFlow)) y en flujos .persistent() reanudados tras un reinicio. Si la espera se hizo en el mismo .do() (await c.waitText()), reintenta en el sitio.
  • Límite: cada c.retry() cuenta como un intento inválido de esa espera, sumado a los rechazos de validate. Al llegar a maxAttempts (por defecto 3) se lanza RetriesExceeded y la conversación termina con retriesMessage. Cada espera nueva empieza en cero. Para reintentos ilimitados usa maxAttempts: Infinity.
  • Sin una espera previa falla con RetryUnavailableError (va al evento 'error' y se envía errorMessage).
  • Haz siempre await (o return). Una conversación tiene un solo hueco de espera pendiente: una espera nueva iniciada mientras un retry sin await sigue esperando falla con ConcurrentWaitError.
  • No lo llames dentro de validate (ahí devuelve un string de error). Si envuelves un paso en try/catch, relanza ConversationError (if (e instanceof ConversationError) throw e).

c.idle(): inactividad dentro de una conversación

  • El callback corre una vez cuando pasa duración ('30s', '2m', '1h', '1d', '10 minutes' o ms, hasta unos 24,8 días) sin mensajes del usuario de la conversación. Cada mensaje entrante lo reprograma, aunque la espera pendiente lo filtre; tras dispararse, el siguiente mensaje lo vuelve a armar.
  • Llamar otra vez a c.idle() reemplaza el anterior. Se desarma al terminar la conversación, con /reset o bot.abortConversation, con goto y con bot.stop(), así que no deja temporizadores colgados.
  • Dentro del callback: c.end(despedida?) o c.goto('menu') terminan o redirigen la conversación en la espera pendiente. c.retry(msg?) reenvía la pregunta pendiente como recordatorio y sigue esperando la misma respuesta (cuenta para maxAttempts; para un recordatorio que no cuente, usa ctx.reply). Si no hay espera pendiente no hace nada. El callback no puede iniciar esperas nuevas. Otros errores van al evento 'error' y la conversación sigue.
  • Es independiente del timeout de la espera, que termina la conversación con timeoutMessage. Funciona en los .do() de un flujo y sigue activo entre pasos. Los comandos que atiende el dispatcher no cuentan como actividad. No se persiste (no sobrevive a un reinicio ni a un goto).

bot.onIdle(): inactividad por chat

  • Por chat (ctx.from); en un grupo cuenta cualquier participante.
  • Se dispara una vez por periodo, contado desde el último mensaje entrante que el bot terminó de procesar. Cada mensaje nuevo lo reprograma y lo vuelve a armar.
  • Los mensajes descartados antes de terminar el pipeline (un hook before('message') que aborta, el filtro groupMode) no cuentan como actividad.
  • bot.stop() lo cancela todo; los registros sobreviven a stop/start.
  • Handoff humano: los temporizadores de un chat en handoff se cancelan y quedan suspendidos (sus mensajes no los rearman) hasta el resume, también para los handoffs restaurados del store al arrancar.
  • Los errores del handler (síncronos o async) van al evento 'error' con el ctx.
  • Usa el Scheduler del bot. Una duración inválida (≤ 0, no finita o mayor que 2^31 − 1 ms) lanza RangeError. Dentro de un plugin, pasa el unsubscribe devuelto a ctx.onDispose.

Revisar tus flujos

bot.describe() dibuja tus flujos como un diagrama Mermaid y bot.lint() avisa de goto rotos, pasos inalcanzables, triggers duplicados y más. Ver Inspeccionar y validar flujos.

Siguientes pasos