Conceptos básicos
Entiende cómo el Dispatcher decide qué pasa con cada mensaje entrante, y las piezas con las que compones un bot.
El pipeline de entrada
Todo mensaje entrante pasa por el mismo pipeline, en este orden. Los cuatro primeros pasos corren en el Bot en cuanto el provider emite el evento; el resto corre dentro del Dispatcher, en una cola FIFO por chat (dos mensajes del mismo chat nunca se procesan en paralelo). Lo primero que consume el mensaje gana y detiene el enrutado:
flowchart TD
A[Entra un mensaje] --> ACL{"¿En la blacklist /<br/>fuera de la whitelist?"}
ACL -->|Sí| X1[Descartado: sin persistir, sin hooks,<br/>no se marca como visto]
ACL -->|No| DD{"¿Ya visto?<br/>(dedupe: chatId + id)"}
DD -->|Sí| X0[Descartado]
DD -->|No| P[Persistir chat, contacto, mensaje]
P --> GM{"Filtro groupMode"}
GM -->|Ignorado| X2[Se detiene aquí]
GM -->|OK| L["Resolver ctx.lang (i18n)"]
L --> B["hooks before('message')<br/>(ctx.lang se re-resuelve si un hook cambió ctx.body)"]
B --> HO{"¿Chat en handoff<br/>con un humano?"}
HO -->|"Sí (salvo allowCommands)"| J
HO -->|No| C{"¿Es un comando? ej. /reset"}
C -->|Sí| D[Handler del comando]
C -->|No| E{¿Hay una conversación activa<br/>para este participante?}
E -->|Sí| F[Continúa el paso de la conversación / flujo]
E -->|No| R{¿Checkpoint dormido de un<br/>flujo persistente?}
R -->|Sí| F
R -->|No| G{"Matchers por especificidad<br/>(WELCOME primero)"}
G -->|Sí| H[Se ejecuta el handler]
G -->|"Sin match / ctx.pass()"| I[onMessage catch-all]
D --> J["hooks after('message')"]
F --> J
H --> J
I --> J
J --> K["se emite el evento 'message'"]
- Blacklist / whitelist: un remitente o chat bloqueado se descarta en silencio, antes que nada. No se persiste, no corren hooks, no se emite
'message'y el dedupe no lo marca como visto, así que una re-entrega después de desbloquearlo se procesa. Ver Handoff y listas de acceso. - Dedupe de entrada: un mensaje o interacción re-entregado (mismo
chatId+ id, por reintentos de webhook o por la resincronización offline de Baileys) se descarta. Activado por defecto; ver Dedupe de entrada. Con el procesamiento durable activado (opt-in), después el mensaje se anota en el inbox antes de que corra nada más, se confirma cuando sus pasos de conversación se asentaron y se reprocesa desde el paso 1 tras una caída. - Persistencia: se guardan el chat, el contacto y el mensaje en el store.
- Filtro
groupMode: un mensaje de grupo que el bot debe ignorar se detiene aquí (sí se persiste). - Resolución del idioma y hooks
before('message'): con el i18n activado, primero se resuelvectx.lang(preferencia guardada enctx.user→ idioma que informa el provider →detect→ idioma por defecto), así que hooks y handlers ya lo ven (ctx.langSourcedice cuál ganó). Después corren los hooks, que pueden hacerabort()del mensaje. Si un hook reescribióctx.body(p. ej. la transcripción del plugin de voz) y el idioma salió dedetecto del valor por defecto, se vuelve a resolver para quedetectvea el texto final (se respeta unctx.langque el hook haya puesto a mano). - Gate de handoff: mientras un chat está en manos de un humano, se saltan los pasos 7–11 (salvo los comandos de
handoff.allowCommands). El mensaje se sigue persistiendo, los hooks siguen corriendo y'message'se sigue emitiendo. - Los comandos (
/reset,/help, ...) siempre ganan, incluso sobre una conversación activa. Eso es lo que le permite a/resetescapar de un flujo atascado. - Conversación activa: si este participante ya inició un
flowo unbot.conversationque está esperando una respuesta, el mensaje va ahí. - Reanudación dormida: si un flujo
.persistent()quedó guardado como checkpoint en el store (p. ej. antes de un reinicio), el flujo se reanuda en el paso guardado. - Matchers registrados con
bot.when(...), por especificidad: primeroWELCOME(solo el primer contacto), luego los triggers de texto (una coincidencia exacta/equalsgana astartsWith, a un regex o acontains), luego las funciones predicado, luego los triggers de un tipo (IMAGE,onImage...) y por último los multi-tipo (MEDIA). Con la misma especificidad gana el registrado primero. bot.onMessage(): el catch-all (tambiénaiPlugin). Se ejecuta solo si nada de lo anterior consumió el mensaje.- Cierre: hooks
after('message')y luego el evento'message'(aquí se rearman los temporizadores debot.onIdle).
Cuando el bot corre como una de varias réplicas (coordination), hay un paso más entre el 4 y el 5: el mensaje tomó un turno en la cola FIFO del chat compartida entre réplicas al encolarse, y espera ahí su turno. Solo una réplica tiene el turno de un chat a la vez y los turnos siguen el orden de llegada, así que la cola FIFO por chat se mantiene entre réplicas. Un mensaje que no consigue su turno se descarta y se informa como 'error' (su id de dedupe se libera). El turno se suelta cuando la conversación que recibió el mensaje vuelve a esperar o termina.
Las llamadas entrantes siguen un camino aparte: los gates de listas de acceso y handoff van primero y bloquean la llamada por completo (sin hooks 'call', sin evento 'call' ni trigger CALL; cuenta en metrics().dropped). Una llamada admitida corre los hooks before/after('call') y el evento 'call', después la misma resolución del idioma y solo los triggers CALL. Sin hooks de mensaje, comandos ni evento 'message'.
bot.trigger(name, { chatId }) (arrancar un flujo sin mensaje entrante; el obsoleto bot.triggerFlow() es un wrapper fino sobre él con ifActive: 'replace') también va por su propio camino: las listas de acceso, después el turno de la cola FIFO del chat, el gate de handoff, la resolución del idioma y el inicio de la conversación. Sin dedupe, persistencia, hooks de mensaje, matchers ni evento 'message'.
Si un handler lanza, corren los hooks onError('message') y el error se emite siempre en bot.on('error', ({ error, ctx }) => ...), aunque no haya ningún hook de error registrado.
Los handlers consumen por defecto: una vez que uno se ejecuta, el pipeline se detiene ahí. Llama a ctx.pass() dentro de un handler para ceder el turno al siguiente handler que coincida en lugar de detenerse — así es como los handlers de baja especificidad como onImage/onType cooperan con un when más amplio:
bot.when(/.+/, async (ctx) => {
if (ctx.type === 'image') { ctx.pass(); return } // cede el turno al siguiente handler
await ctx.reply('text received')
})
bot.onType('image', async (ctx) => {
await ctx.reply('image received') // ahora se alcanza, porque el handler de arriba hizo pass
})1. Bot + Provider — el centro
import { Bot } from '@botmesh/core'
import { BaileysProvider } from '@botmesh/baileys'
const bot = new Bot({ provider: new BaileysProvider({ session: 'my-bot' }) })
await bot.start()El Bot (de @botmesh/core) es el motor agnóstico de transporte: ejecuta el Dispatcher, los hooks, los flujos, las conversaciones y el scheduler. El provider es la frontera del transporte — @botmesh/baileys se conecta a WhatsApp; @botmesh/meta (Cloud API oficial), @botmesh/telegram y los transportes experimentales de Messenger, Instagram y Discord se enchufan al mismo motor. Todos los transportes alimentan al bot con los mismos eventos normalizados.
Los envíos necesitan el bot en marcha: antes de bot.start() y después de bot.stop(), bot.send(), ctx.reply() y ctx.delete() rechazan con BotNotRunningError y no llega nada al provider (un listener de 'ready' ya puede enviar). Las señales de presencia (ctx.typing(), ctx.markRead(), el indicador automático de escritura) son de mejor esfuerzo: mientras el transporte está desconectado o parado se omiten sin error, se registran en debug y se cuentan en metrics().presenceSkipped. Las llamadas concurrentes a start() / stop() se ejecutan de una en una, en orden; cada stop() de un bot en marcha emite 'stopping' antes del drenado y de parar el transporte, y después 'stopped'. Un evento que rompe el contrato del provider se descarta y se informa en 'error' como InvalidProviderEventError; un evento de un tipo que el core no conoce (un provider más nuevo) se ignora.
2. when — respuestas simples
bot.when('hola', async (ctx) => {
await ctx.reply('👋 Hello!')
})Cuando el mensaje coincide con el trigger, ejecuta esta función. La estrategia de coincidencia por defecto es igualdad normalizada: minúsculas, sin acentos, espacios colapsados — 'hola' coincide con "hola"/"Hóla ", pero NO con "/hola" ni "hola amigo". Los triggers aceptan texto, una lista, un regex, un predicado, o un objeto explícito { pattern, match, scope }:
bot.when('hola', handler)
bot.when(['bye', 'goodbye'], handler)
bot.when(/\d{4,}/, handler)
bot.when({ pattern: 'factura', match: 'contains' }, handler) // estilo v1: "contains"3. flow / conversation — conversaciones multi-paso
const register = flow('registrar')
.ask('Name?', 'name')
.say((_ctx, c) => `Hi ${c.state.name}`)
bot.use(register)Un flow compila a una conversación async real: el bot pregunta, el usuario responde, c.state lo guarda y el flujo continúa — con validación, reintentos, branching (choose) y timeouts. Para tener control manual total puedes saltarte el DSL y escribir directamente bot.conversation(name, async (c, ctx) => {...}).
4. Hooks — middleware (before / after / onError)
bot.before('message', async (ctx, next) => {
console.log('📥', ctx.from, ctx.body)
await next()
})
bot.after('message', async (ctx) => {
console.log('📤 processed')
})
bot.onError('message', async (ctx, error) => {
console.error('💥 error:', error)
})Los hooks se ejecutan antes o después de cada mensaje, como el middleware de Express. 'message' es el evento principal de hooks; 'send' se dispara en cada envío saliente. Un hook before que vuelve sin llamar a next() detiene el evento, igual que ctx.abort().
5. Plugins — funcionalidad empaquetada
bot.plug(slashCommandsPlugin, { greeting: 'Hi!' })Un plugin es un paquete reutilizable que puede registrar hooks, handlers y herramientas. bot.plug() devuelve una función unplug() que revierte lo que registró vía ctx.bot (handlers, hooks, plugins anidados, flows en caliente y callbacks de onDispose; listeners, herramientas, idiomas y tareas programadas necesitan un onDispose, ver Plugins).
Un plugin se enchufa una vez por bot: enchufar un plugin cuyo name ya está enchufado lanza un error, salvo que todas las instancias pongan multiple: true en su spec.
Dedupe de entrada
Los transportes por webhook (Cloud API, Messenger, Instagram, Telegram en modo webhook) reintentan una entrega cuando no reciben un 2xx a tiempo, y Baileys puede reenviar mensajes recientes al reconectar. Sin protección, un mismo mensaje arrancaría un flujo dos veces o se consumiría como la respuesta a la siguiente pregunta de una conversación. El Bot descarta las re-entregas antes de que lleguen a ningún handler. Viene activado por defecto:
new Bot({ provider }) // por defecto: { size: 1000 }, sin TTL
new Bot({ provider, dedupe: { size: 5000, ttl: '1h' } }) // ajustar
new Bot({ provider, dedupe: false }) // desactivar- La clave de un mensaje es
chatId+ref.id, porque algunos transportes solo garantizan ids únicos por chat (p. ej. elmessage_idde Telegram). La de una interacción (pulsar un botón o una fila de lista) es suinteractionId, así que dos pulsaciones en el mismo menú son dos eventos. - Ediciones: el mismo id con otro cuerpo (
edited_messagede Telegram) se procesa; un reintento idéntico se descarta. Solo se guarda un hash del cuerpo, nunca el texto. - Los eventos con id vacío nunca se deduplican.
- Las listas de acceso van antes: un mensaje o interacción de un remitente o chat bloqueado nunca se registra, así que el mismo id re-entregado después de desbloquearlo se procesa (y, mientras siga bloqueado, cuenta como
blocked, no comoduplicate). - Si falla la persistencia antes de los handlers (store caído), la clave se libera y el reintento del transporte sí se procesa.
Filtro de ecos
Cada provider marca sus propios mensajes con fromMe, y el core los descarta antes que nada. Algunos transportes solo conocen su propio id tras un handshake, y un buzón o una pasarela puede devolver un mensaje que el bot acaba de enviar sin esa marca. echoFilter complementa a fromMe (no lo sustituye): el bot recuerda los refs de sus envíos correctos y descarta un mensaje entrante cuyo ref esté recordado, antes de las listas de acceso, el dedupe y el journal, así que un eco nunca cuenta como tráfico entrante.
new Bot({ provider, echoFilter: true }) // 1000 refs, sin TTL
new Bot({ provider, echoFilter: { size: 5000, ttl: '10m' } }) // ajustarLos descartes cuentan en metrics().dropped.echo (Prometheus: botmesh_dropped_total{reason="echo"}) y solo con la opción activa; sin ella el bot se comporta exactamente igual que antes. El anillo vive en memoria (por proceso) y cada ref recordado lo consume el primer eco que coincide; una re-entrega posterior es cosa del dedupe.
- Un mensaje que seguía en la cola de su chat cuando se llamó a
bot.stop()se descarta sin procesar y su clave se libera, así que el mismo id re-entregado después debot.start()sí se procesa. sizees un LRU;ttl(ms o'30s'/'10m'/'1h') cuenta desde la primera aceptación.- Límite: vive en la memoria del proceso. No deduplica entre réplicas ni tras un reinicio.
Los providers deben dar a cada evento entrante un id que nombre exactamente un evento lógico, que no cambie cuando se re-entrega y que sea '' cuando el transporte no pueda garantizarlo. Es el contrato de ids de entrada, documentado en IProvider. El deduper en sí (InboundDeduper, inboundDedupeKey…) es un interno del motor (@botmesh/core/internal).
El evento 'message' vs onMessage()
Se parecen pero no son lo mismo:
bot.on('message', ctx => ...)se dispara para todo mensaje que el Dispatcher terminó de procesar — incluyendo los que un comando, una conversación activa, o un handlerwhenya consumieron.bot.onMessage(ctx => ...)es el catch-all — solo se ejecuta cuando nada más consumió el mensaje.
Las conversaciones de grupo son por participante por defecto
En un grupo, dos participantes distintos que le hablan al bot en el mismo chat obtienen conversaciones independientes por defecto (indexadas internamente por ${from}:${sender}). Usa conversationScope: 'chat' si quieres una única conversación compartida por chat de grupo:
const bot = new Bot({
provider: new BaileysProvider({ session: 'my-bot' }),
conversationScope: 'chat',
})El objeto ctx — tu mejor amigo
bot.when('hola', async (ctx) => {
console.log(ctx.body) // texto del mensaje
console.log(ctx.from) // id del chat (opaco; un JID en WhatsApp/Baileys)
console.log(ctx.sender) // id del remitente (= from en DMs, participant en grupos)
console.log(ctx.type) // 'text', 'image', 'audio', ...
console.log(ctx.isGroup) // ¿es un grupo?
await ctx.reply('text')
await ctx.photo(url, 'caption')
await ctx.poll('Question?', ['A', 'B'])
await ctx.react('👍')
const db = ctx.tools.db // herramienta inyectada
await ctx.user.set('lang', 'es') // perfil persistente por usuario
})Ids de chat y remitente
ctx.from (chat) y ctx.sender (usuario) son strings opacos que el provider resuelve — el core nunca inspecciona su forma. Para el transporte Baileys resultan ser JIDs (Jabber IDs) de WhatsApp:
| Tipo | Formato | Ejemplo |
|---|---|---|
| Usuario | number@s.whatsapp.net | 573001234567@s.whatsapp.net |
| Grupo | id@g.us | 120363xxx@g.us |
| Newsletter | id@newsletter | 120363xxx@newsletter |
No necesitas memorizar esto — ctx.from te da el id correcto en cualquier transporte. Si necesitas la clave nativa del mensaje entrante (para citarlo/editarlo/borrarlo con llamadas del provider), usa ctx.ref ({ id, chatId, native? }).
Duraciones
Toda opción de duración acepta un texto de duración ('500ms', '30s', '2m', '1h', '1d', '1w', '5 minutes') o un número de milisegundos. Eso incluye todas las opciones cuyo nombre termina en Ms: typing.minMs, durability.inbox.backoff.baseMs, coordination.turnWaitMs, scheduler.tickMs y el resto.
const bot = new Bot({
provider,
typing: { minMs: '800ms', maxMs: '3s' },
durability: { pollMs: '1s', outbox: { inlineMaxMs: '15s', backoff: { baseMs: '1s', capMs: '1m' } } },
})new Bot() revisa todas las opciones de duración de BotConfig. Un valor inválido lanza un ConfigValidationError cuyo field es la ruta de la opción, así que el mensaje dice cuál corregir:
ConfigValidationError: Invalid config.durability.pollMs: expected a duration ('500ms', '30s', '10m', '1h') or milliseconds, got string ("soon")parseDuration() expone la misma gramática.
Logger
BotConfig.logger (y la opción logger de cada provider y store) acepta un LoggerLike: pino, console o cualquier objeto con debug, info, warn y error. Los paquetes siempre lo llaman al estilo pino, así que un logger propio solo tiene que manejar dos formas:
logger.warn('provider reconnected') // un mensaje
logger.error({ err, chatId }, 'dispatch error') // primero el objeto de contexto, luego el mensajeUn error va siempre bajo la clave err. Los paquetes nunca ponen un objeto después del mensaje (pino lo descartaría), y child(bindings) es opcional.
Entradas del paquete
| Importa de | Para |
|---|---|
@botmesh/core | Aplicaciones: el Bot, el DSL de flujos, los tipos de mensaje, las opciones y los errores que puedes capturar. |
@botmesh/core/testing | Tests: createTestBot, MockProvider, el kit de caos, las transcripciones doradas, la simulación de caídas y MemoryCoordinator. |
@botmesh/core/spi | Autores de stores, backends de durabilidad, schedule stores, coordinadores, integraciones de trazas y transportes: KvDurability, MemoryKv, MemoryScheduleStore, messageKey, toInboundLocation, los tipos de registro… Con garantía semver. |
@botmesh/core/dev | El simulador de terminal botmesh dev. |
@botmesh/core/internal | Clases del motor (Dispatcher, ConversationRuntime, HookSystem…). Sin garantía semver: no lo uses desde una aplicación. |
Siguientes pasos
- Mensajes y media — todos los tipos de respuesta
- Flujos conversacionales — conversaciones complejas
- Hooks — middleware y logging
- Triggers especiales — WELCOME, tipos de mensaje y CALL
- Handoff y listas de acceso — pausar el bot para un humano, bloquear remitentes
- Ejemplos — recetas prácticas