> ## Documentation Index
> Fetch the complete documentation index at: https://botmesh.afaya.com.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Hooks (Middleware)

> Hooks are functions that run before or after an event. Like Express middleware: intercept, modify, decide whether to continue.

## The 3 types

```mermaid theme={null}
flowchart LR
    A[Event] --> B[before hooks]
    B --> C[Handler / Flow / Send]
    C --> D[after hooks]
    C -.->|error| E[onError hooks]
```

### before — before processing

```ts theme={null}
bot.before('message', async (ctx, next) => {
  console.log('📥', ctx.type)
  await next()  // ← important: call next() to continue
})
```

> [!WARNING]
>
> **Forgetting next() blocks the event**
>
> A `before` hook that returns **without calling `next()`** stops the chain
> and the event, exactly like `ctx.abort()`: the message is not routed (a
> `'send'` is skipped, a provider event is not emitted). A hook that only
> observes or rewrites must always end with `await next()`. To block on
> purpose (rate limiting, filters) either return without `next()` or call
> `ctx.abort()` (see `abort()` below) — `abort()` reads more explicitly.
>
> On `'message'`, `next()` runs the remaining `before` hooks; routing (the
> command, conversation or handler) starts after every `before` hook has
> returned. To act after the handler, use an `after` hook.
>
> Call `next()` **once**. A hook that calls it again keeps the first dispatch
> (the rest of the chain runs once; every later `next()` returns the same
> promise) and the misuse is reported on the `'error'` event.

### after — after processing

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

### onError — when something fails

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

There's also a generic `bot.hook(event, phase, handler, priority?)` if you
need to register a phase dynamically instead of calling `before`/`after`/
`onError` directly.

A handler error is **always** emitted on `bot.on('error', ({ error, ctx }) => ...)`,
whether or not an `onError('message')` hook is registered.

## Use cases

### Logging

```ts theme={null}
const startedAt = new WeakMap<object, number>()

bot.before('message', async (ctx, next) => {
  startedAt.set(ctx, Date.now())
  await next()          // resolves once the remaining before hooks ran, not the handler
})

bot.after('message', (ctx) => {
  bot.logger.info({ type: ctx.type, ms: Date.now() - (startedAt.get(ctx) ?? Date.now()) }, 'message handled')
})
```

Chat ids are phone numbers or emails and bodies are private messages: keep
them out of your logs, or hash them the way the bot does with
[`logIds`](https://botmesh.afaya.com.co/guide/configuration#logging).

### Rate limiting

```ts theme={null}
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()
})
```

### Filter groups

```ts theme={null}
bot.before('message', async (ctx, next) => {
  if (ctx.isGroup) return  // no next(): the message stops here (same as ctx.abort())
  await next()
})
```

For group messages the built-in [`groupMode`](https://botmesh.afaya.com.co/guide/concepts#the-inbound-pipeline) option is usually
simpler: it filters before the hooks even run.

### Block users

```ts theme={null}
const blocked = new Set(['57300XXXXXXX@s.whatsapp.net'])

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

For a real block list prefer the built-in `bot.blacklist` / `bot.whitelist`:
they drop the message before it is even persisted and also cover button
taps and calls. See [Handoff & access lists](https://botmesh.afaya.com.co/guide/handoff.md#blacklist-and-whitelist).

## Transform or block outgoing messages

`'send'` fires on **every** outbound send, whether it comes from
`ctx.reply`/`ctx.photo`/... or from `bot.send`. The payload
is `{ chatId, content, options }` — `content` is the transport-neutral
`OutboundContent` (`{ kind: 'text', text }`, `{ kind: 'image', media,
caption }`, ...); mutate it to transform a message, or abort to skip the
send entirely:

```ts theme={null}
bot.before('send', async (payload, next) => {
  console.log('📤 sending', payload.content.kind)
  await next()
})
```

An aborted send resolves `{ skipped: true }` instead of rejecting (a
[broadcast](https://botmesh.afaya.com.co/guide/messages.md#broadcasts) counts it as `skipped`).

### Edits and streamed output

Some payloads carry a marker so a hook can tell them apart from a plain send:

| Marker | Sent by | Hooks that run |
| - | - | - |
| `edit: ref` | [`ctx.edit()`](https://botmesh.afaya.com.co/guide/messages#edit-a-message) editing a message in place | `before` + `after` |
| `stream: 'message'` | a new message [`ctx.stream()`](https://botmesh.afaya.com.co/guide/streaming) sends (the first message of an edit stream, a sentence chunk, the final message after drafts) | `before` + `after` |
| `stream: 'edit'` | a live update of a streamed message | `before` only |
| `stream: 'draft'` | a native draft preview (Telegram) | `before` only |

A hook can rewrite `payload.content` or `abort()` any of them. An aborted edit
resolves `skipped` and sends no new message in its place. A degraded edit (the
provider cannot edit) goes out as a normal send, without `edit`.

```ts theme={null}
let sentCount = 0
bot.before('send', async (payload, next) => {
  // Redact card numbers everywhere, live previews included
  if (payload.content.kind === 'text') {
    payload.content.text = payload.content.text.replace(/\b\d{13,16}\b/g, '[card]')
  }
  // Count new messages only: live previews repeat the same text many times
  if (payload.stream !== 'draft' && payload.stream !== 'edit' && !payload.edit) sentCount++
  await next()
})
```

## abort() — stop the pipeline

`abort()` stops the remaining `before` hooks and the event itself. Returning
without `next()` has the same effect; `abort()` also works from code that has
no `next` at hand (a helper that receives only `ctx`).

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

## Priority

```ts theme={null}
bot.before('message', handler, 10)  // higher = runs first
bot.before('message', handler, 0)   // default
```

## Typed contexts

The context a hook receives is typed by the event name (`HookContextMap`):
`'message'` gives a `MessageContext`, `'send'` a `SendHookContext`
(`{ chatId, content, options?, stream?, edit? }`), `'call'` a `CallEvent`,
`'reaction'` an `InboundReaction`, and so on; `before` hooks also get
`abort()`. No annotation is needed:

```ts theme={null}
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 is a MessageContext, error is unknown */ })
```

A custom event (one a plugin runs its own hooks for) is `unknown` unless you
annotate the handler, pass the type argument, or augment `HookContextMap`
(declaration merging):

```ts theme={null}
declare module '@botmesh/core' {
  interface HookContextMap { 'crm.lead': { leadId: string } }
}
bot.after('crm.lead', (lead) => { console.log(lead.leadId) })              // typed through the augmentation
bot.after('other', (ctx: { n: number }) => { console.log(ctx.n) })         // or annotate the handler
bot.after<'other', { n: number }>('other', (ctx) => { console.log(ctx.n) }) // or pass the type argument
```

`HookHandler<TCtx = unknown>` and `HookErrorHandler<TCtx = unknown>` name a
handler's type; a handler typed for one event (`HookHandler<MessageContext>`)
cannot be registered on another (`'send'`).

## Available events

| Event | When |
| - | - |
| `message` | The main pipeline — an incoming message being processed |
| `send` | Any outbound message, from a `ctx` reply helper or `bot.send()` (also edits and streamed output, see above) |
| `reaction`, `poll`, `group`, `presence`, `status` | Provider events (a reaction, a poll vote, a group update, presence, a delivery/read receipt): `before` and `after` hooks run, then the matching `bot.on(...)` event. A `before` hook that aborts (or skips `next()`) suppresses the `after` hooks and the event |
| `call` | An incoming call. `before('call', (ev) => ev.abort())` keeps it away from [`CALL` triggers](https://botmesh.afaya.com.co/guide/triggers#call), the `after('call')` hooks and the `'call'` event. A call blocked by the access lists or a handoff never reaches the `'call'` hooks |

Provider-level connection events (`qr`, `ready`, `connection`, ...) are
plain `bot.on(...)` listeners, not hook phases — see [Core concepts](https://botmesh.afaya.com.co/guide/concepts.md).
The same goes for `'conversation:started'` / `'conversation:ended'`
(see [Operations](https://botmesh.afaya.com.co/guide/operations.md#conversation-events)).

## Next steps

* [Plugins](https://botmesh.afaya.com.co/guide/plugins.md) — package hooks for reuse
* [Example: rate limiter](https://botmesh.afaya.com.co/examples/rate-limiter.md) — full plugin


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.