Skip to content

Hooks (middleware) ​

Los hooks son funciones que se ejecutan antes o después de un evento. Como el middleware de Express: interceptan, modifican, deciden si continuar.

Los 3 tipos ​

flowchart LR
    A[Evento] --> B[hooks before]
    B --> C[Handler / Flow / Send]
    C --> D[hooks after]
    C -.->|error| E[hooks onError]

before — antes de procesar ​

ts
bot.before('message', async (ctx, next) => {
  console.log('📥', ctx.from, ':', ctx.body)
  await next()  // ← importante: llama next() para continuar
})

Olvidar next() bloquea el evento

Un hook before que vuelve sin llamar a next() detiene la cadena y el evento, igual que ctx.abort(): el mensaje no se enruta (un 'send' se omite, un evento del provider no se emite). Un hook que solo observa o reescribe debe terminar siempre con await next(). Para bloquear a propósito (rate limiting, filtros), vuelve sin next() o llama a ctx.abort() (ver abort() más abajo); abort() se lee más explícito.

Llama a next() una sola vez. Un hook que vuelve a llamarlo conserva el primer despacho (el resto de la cadena corre una vez y cada next() posterior devuelve la misma promesa) y el mal uso se informa en el evento 'error'.

after — después de procesar ​

ts
bot.after('message', async (ctx) => {
  console.log('📤 processed:', ctx.type)
})

onError — cuando algo falla ​

ts
bot.onError('message', async (ctx, error) => {
  console.error('💥 error:', error)
  if (ctx) await ctx.reply('😅 An error occurred. Try again.')
})

También existe un bot.hook(event, phase, handler, priority?) genérico si necesitas registrar una fase dinámicamente en lugar de llamar directamente a before/after/onError.

El error de un handler se emite siempre en bot.on('error', ({ error, ctx }) => ...), haya o no un hook onError('message') registrado.

Casos de uso ​

Logging ​

ts
bot.before('message', async (ctx, next) => {
  console.log(`📥 [${new Date().toISOString()}] ${ctx.from}: ${ctx.body}`)
  await next()
})

Rate limiting ​

ts
const counts = new Map()

bot.before('message', async (ctx, next) => {
  const now = Date.now()
  let entry = counts.get(ctx.from)

  if (!entry || entry.resetAt < now) {
    entry = { count: 0, resetAt: now + 60000 }
    counts.set(ctx.from, entry)
  }

  entry.count++
  if (entry.count > 10) {
    await ctx.reply('⏳ Too many messages. Wait a minute.')
    ctx.abort()
    return
  }

  await next()
})

Filtrar grupos ​

ts
bot.before('message', async (ctx, next) => {
  if (ctx.isGroup) return  // sin next(): el mensaje se detiene aquí (igual que ctx.abort())
  await next()
})

Para mensajes de grupo suele ser más simple la opción integrada groupMode, que filtra antes incluso de que corran los hooks.

Bloquear usuarios ​

ts
const blocked = new Set(['57300XXXXXXX@s.whatsapp.net'])

bot.before('message', async (ctx, next) => {
  if (blocked.has(ctx.from)) {
    ctx.abort()
    return
  }
  await next()
})

Para una lista de bloqueo de verdad usa bot.blacklist / bot.whitelist: descartan el mensaje antes incluso de persistirlo y también cubren botones y llamadas. Ver Handoff y listas de acceso.

Transformar o bloquear mensajes salientes ​

'send' se dispara en cada envío saliente, ya sea que venga de ctx.reply/ctx.photo/... o de bot.send. El payload es { chatId, content, options } — content es el OutboundContent neutral de transporte ({ kind: 'text', text }, { kind: 'image', media, caption }, ...); mútalo para transformar un mensaje, o aborta para saltarte el envío por completo:

ts
bot.before('send', async (payload, next) => {
  console.log('📤 sending to', payload.chatId, payload.content)
  await next()
})

Un envío abortado resuelve { skipped: true } en lugar de rechazar (una difusión lo cuenta como skipped).

Ediciones y salida en streaming ​

Algunos payloads llevan una marca para que un hook los distinga de un envío normal:

MarcaLo envíaHooks que corren
edit: refctx.edit() al editar un mensaje en el sitiobefore + after
stream: 'message'un mensaje nuevo que envía ctx.stream() (el primero de un stream por ediciones, un trozo por frases, el mensaje final tras los borradores)before + after
stream: 'edit'una actualización en vivo de un mensaje en streamingsolo before
stream: 'draft'una vista previa nativa como borrador (Telegram)solo before

Un hook puede reescribir payload.content o hacer abort() en cualquiera de ellos. Una edición abortada resuelve skipped y no envía un mensaje nuevo en su lugar. Una edición degradada (el provider no puede editar) sale como un envío normal, sin edit.

ts
let sentCount = 0
bot.before('send', async (payload, next) => {
  // Oculta números de tarjeta en todo, vistas previas incluidas
  if (payload.content.kind === 'text') {
    payload.content.text = payload.content.text.replace(/\b\d{13,16}\b/g, '[card]')
  }
  // Cuenta solo mensajes nuevos: las vistas previas repiten el mismo texto muchas veces
  if (payload.stream !== 'draft' && payload.stream !== 'edit' && !payload.edit) sentCount++
  await next()
})

abort() — detener el pipeline ​

abort() detiene los hooks before restantes y el propio evento. Volver sin next() tiene el mismo efecto; abort() sirve además desde código que no tiene next a mano (un helper que solo recibe ctx).

ts
bot.before('message', async (ctx, next) => {
  if (someCondition) {
    ctx.abort()
    return
  }
  await next()
})

Prioridad ​

ts
bot.before('message', handler, 10)  // mayor = se ejecuta primero
bot.before('message', handler, 0)   // valor por defecto

Contextos tipados ​

El contexto que recibe un hook se tipa por el nombre del evento (HookContextMap): 'message' da un MessageContext, 'send' un SendHookContext ({ chatId, content, options?, stream?, edit? }), 'call' un CallEvent, 'reaction' un InboundReaction, etc.; los hooks before reciben además abort(). No hace falta anotar nada:

ts
bot.before('message', async (ctx, next) => { console.log(ctx.body); await next() })
bot.after('send', (payload) => { console.log(payload.content.kind) })
bot.onError('message', (ctx, error) => { /* ctx es un MessageContext, error es unknown */ })

Un evento propio (uno para el que un plugin corre sus propios hooks) es unknown salvo que anotes el handler, pases el argumento de tipo o amplíes HookContextMap (declaration merging):

ts
declare module '@botmesh/core' {
  interface HookContextMap { 'crm.lead': { leadId: string } }
}
bot.after('crm.lead', (lead) => lead.leadId)              // tipado por la ampliación
bot.after('other', (ctx: { n: number }) => ctx.n)         // o anota el handler
bot.after<'other', { n: number }>('other', (ctx) => ctx.n) // o pasa el argumento de tipo

HookHandler<TCtx = unknown> y HookErrorHandler<TCtx = unknown> nombran el tipo de un handler; un handler tipado para un evento (HookHandler<MessageContext>) no se puede registrar en otro ('send').

Eventos disponibles ​

EventoCuándo
messageEl pipeline principal — un mensaje entrante siendo procesado
sendCualquier mensaje saliente, desde un helper de respuesta de ctx o bot.send* (también ediciones y salida en streaming, ver arriba)
reaction, poll, group, presence, statusEventos del provider (una reacción, un voto de encuesta, un cambio de grupo, presencia, un acuse de entrega/lectura): corren los hooks before y after y luego el evento bot.on(...) correspondiente. Un hook before que aborta (o no llama a next()) suprime los hooks after y el evento
callUna llamada entrante. before('call', (ev) => ev.abort()) la aparta de los triggers CALL, de los hooks after('call') y del evento 'call'. Una llamada bloqueada por las listas de acceso o por un handoff nunca llega a los hooks 'call'

Los eventos de conexión a nivel de provider (qr, ready, connection, ...) son listeners bot.on(...) normales, no fases de hooks — ver Conceptos básicos. Lo mismo vale para 'conversation:started' / 'conversation:ended' (ver Operación).

Siguientes pasos ​

Released under the MIT License.