> ## 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.

# Messages & Media

> Everything you can send and receive.

## Reply helpers (inside a handler)

All of these target `ctx.from` and return `Promise<SentMessage>`. `ctx.reply` is
the text reply (`say`, `answer` and `text` are deprecated aliases of it):

```ts theme={null}
await ctx.reply('👋 Hello!')
await ctx.quote('replying to your message')     // quotes the inbound message
await ctx.reply('typing this out...', { typing: true }) // shows "typing…" before sending
```

Every send (these helpers, `bot.send`, broadcasts, `ctx.edit`) is checked after the
`before('send')` hooks and before the transport: content that no transport can
deliver (a `text` that is not a string, such as `ctx.reply(undefined)`; media
without a source; coordinates outside ±90/±180; a poll or button message with no
options) rejects with a `TypeError` that names the field, and nothing is sent.
Transport limits (number of buttons, media size) are still checked by each provider; a
text longer than the channel allows is split for you (see [Long text](#long-text-is-split-automatically)).
Every `MediaSource` form is accepted, including `{ native }` (a provider-specific
reference such as a Telegram `file_id`).

A `ctx` reply that hits a `RateLimitedError` asking for a wait of up to 5 s is
retried once in place, so a flow step does not end on a transient rate limit
(tune it with `BotConfig.rateLimitRetry: { retries, maxWaitMs }`, or turn it off
with `false`). Longer waits and other errors reject as before; `bot.send()` is
not retried (broadcasts have their own back-off).

### Long text is split automatically

Every channel caps the length of one text message (`capabilities.maxTextLength` in UTF-16
code units, and `capabilities.maxTextBytes` in UTF-8 bytes on Instagram). A `text` send
longer than that — a `ctx` reply, a flow `.say`, `bot.send` or a broadcast — goes out as
several messages, in order, cut at the most natural boundary that fits: paragraph, line,
sentence, then word. A cut never splits an emoji (surrogate pairs, ZWJ sequences, flags,
skin tones) or the chat's markup: links and URLs stay whole, and a code block, inline
code, a `*bold*` run or an HTML element longer than the limit is closed at the end of
one part and reopened at the start of the next.

```ts theme={null}
const sent = await ctx.reply(longAnswer) // 9000 chars on Telegram → 3 messages
sent.id        // the last part
sent.parts     // the ref of every part, in order (only set when the text was split)
```

`before('send')` hooks run **once, on the whole text**, before it is split: a hook that
adds a footer or translates the text changes the whole message, and what it returns is
what gets split, so every part still fits (a hook that makes a short text too long gets
it split too). An `abort()` skips the whole text. Each part is then a send of its own:
`after('send')` hooks, the message log and the durable outbox see every part. Only the
first part keeps `quoted`; an explicit `idempotencyKey` gets a `#2`, `#3`… suffix from
the second part on (from `#1` when a hook made the text too long). If a part fails, the
send rejects and the remaining parts are not sent; with durability, a part the outbox
defers (`SendDeferredError`) is followed by the remaining parts in the outbox, so the
sweeper delivers all of them, in order. `bot.enqueue()` splits too: it journals one
record per part (`key`, `key#2`, …). Text that fits is sent untouched.
`ctx.edit` is never split, and `ctx.stream()` splits on its own. Turn it off with
`new Bot({ splitLongText: false })` (the provider then rejects a long text with
`PayloadTooLargeError`). To split text yourself, use `splitMessage(text, { maxLength,
maxBytes, format })` from `@botmesh/core`.

## Send photo

```ts theme={null}
await ctx.photo('https://picsum.photos/400/300', '📸 caption')
await ctx.photo('./photo.jpg', 'caption')  // local file
await ctx.photo(buffer, 'caption')          // Buffer
```

## Send video

```ts theme={null}
await ctx.video(url, 'caption')
await ctx.video(url, 'caption', { gifPlayback: true })  // GIF
await ctx.video(url, 'caption', { ptv: true })           // circular video
```

## Send audio & voice

```ts theme={null}
await ctx.audio(url)   // audio
await ctx.voice(url)   // voice note (PTT)
```

## Send file

```ts theme={null}
await ctx.file(url, 'document.pdf', 'caption')
```

## Send sticker

```ts theme={null}
await ctx.sticker(url, { pack: 'My Pack', author: 'me' })
```

## Send location

```ts theme={null}
await ctx.location(4.7110, -74.0721, 'Bogotá, Colombia 🇨🇴')
```

## Send contact

```ts theme={null}
await ctx.contact('573001234567', 'Support Bot')
```

## Buttons: normalized, degraded per transport

`ctx.buttons(text, buttons)` sends normalized button content. How it lands
depends on the provider:

* **`@botmesh/baileys`** — native interactive buttons are dead in the
  current WhatsApp Web protocol, so buttons degrade to a **numbered text
  menu**. A numbered menu (`.choose()` in a flow, or `c.choose()` in a
  conversation — see [Flows](https://botmesh.afaya.com.co/guide/flows.md)) or a real poll usually reads better.
* **`@botmesh/meta`** (Cloud API) — native interactive buttons/lists/templates.
* **`@botmesh/telegram`** — native inline keyboards.

```ts theme={null}
await ctx.buttons('Choose one', [
  { id: 'a', title: 'Option A' },
  { id: 'b', title: 'Option B' },
])

// Polls are native on Baileys and Telegram. The Cloud API has no polls:
// there, ctx.poll() rejects with UnsupportedContentError — use buttons.
await ctx.poll('What do you prefer?', ['Option A', 'Option B'])

// Multi select
await ctx.poll('What do you like?', ['🍕 Pizza', '🍔 Burger'], true)
```

If a provider cannot honor a content kind at all, `send` rejects with a
typed `UnsupportedContentError` from `@botmesh/core`.

## Typing indicator and reply delays

`BotConfig.typing` shows "typing…" before every text reply sent through `ctx`
(`reply` and its deprecated aliases, `quote` and flow `.say`). The indicator lasts
`text length / charsPerSecond`, clamped to `[minMs, maxMs]`. It is off by default.

```ts theme={null}
const bot = new Bot({ provider, typing: true }) // defaults: { minMs: 800, maxMs: 3000, charsPerSecond: 20 }
const tuned = new Bot({ provider, typing: { enabled: true, minMs: 500, maxMs: 2000 } })

// Per reply: override typing and/or wait first
await ctx.reply('One moment…', { typing: false })
await ctx.reply('Here it is', { delay: '1.5s', typing: { maxMs: 1000 } })
```

* `typing: true` uses the bot's timing, `typing: false` suppresses it, and an
  object enables it with those overrides. A per-reply `maxMs` below the bot's
  `minMs` lowers the minimum instead of throwing.
* `delay` (ms or `'500ms'`, `'1.5s'`) runs before the indicator. `cancelIf: () => boolean`
  is checked after the wait: when it returns `true` the reply is dropped.
* A failing indicator never fails the send; it is cosmetic and best effort.
* On providers without `capabilities.typing` there is no indicator **and no wait**. `delay` is honored everywhere.
* A paced reply is dropped (it resolves `{ skipped: true }`) if the bot stops, the chat is handed off or the flow is aborted during the wait. `stop({ drain: true })` waits for paced replies.
* `ctx.replyWithTyping(text, msPerChar?)` is **deprecated**: it paces itself (40 ms per
  character by default) and ignores `BotConfig.typing`. Use `ctx.reply(text, { typing: true })`.

Long or AI-generated answers can be streamed instead of sent in one go: see
[Streaming](https://botmesh.afaya.com.co/guide/streaming.md).

## Edit a message

```ts theme={null}
const msg = await ctx.reply('Processing…')
await ctx.edit('Done ✅')                       // edits this context's own last reply
await ctx.edit(msg.ref, 'Done ✅')              // edits a given message
await ctx.edit('Done ✅', { fallback: 'skip' }) // never sends a new message
```

`ctx.edit(content)` only targets the last reply sent by **this same context**
(this handler run, or this flow) and only when it has the same content kind.
It never edits replies to an earlier message, replies of another participant's
flow in a group, or a reply followed by a `bot.send` to the chat.
The result has `edited: true` when the provider edited in place.

When the provider cannot edit (no `capabilities.edit`, a content kind it cannot
edit, or nothing to edit), `ctx.edit` sends the content as a **new message** in
the chat of the edited message (`ref.chatId`), or sends nothing with
`{ fallback: 'skip' }`. Edits run the `before`/`after('send')` hooks (the payload
carries `edit: ref`, see [Hooks](https://botmesh.afaya.com.co/guide/hooks.md#edits-and-streamed-output)) and update the outbound ledger.

## React

```ts theme={null}
await ctx.react('👍')
await ctx.react('❤️')
await ctx.react('🔥', msg.ref) // react to another message
await ctx.react('')            // remove the reaction, where the transport allows it
```

On providers that cannot send reactions, `ctx.react` resolves `{ skipped: true }`
instead of rejecting.

## Support per transport

`ctx.supports(feature)` returns `'native'`, `'degraded'` or `'unsupported'` for
`'typing'`, `'delay'`, `'edit'` and `'react'`:

| Transport | typing | delay | edit | react |
| - | - | - | - | - |
| WhatsApp (Baileys) | native | native | degraded (new message) | native |
| WhatsApp Cloud API | unsupported | native | degraded (new message) | native |
| Messenger | native | native | degraded (new message) | unsupported (skipped) |
| Instagram | native | native | degraded (new message) | unsupported (skipped) |
| Telegram | native | native | native | native |
| Discord | native | native | native | native |

## Other actions

```ts theme={null}
await ctx.markRead()   // mark the inbound message as read
await ctx.typing()     // show "typing…"
await ctx.recording()  // show "recording audio…"
await ctx.delete()     // delete the inbound message (if allowed)
```

Actions degrade to a no-op on providers that lack the capability.
`ctx.typing()` and `ctx.recording()` are best-effort: they never reject. If the
provider fails to show the indicator, the error is logged at `debug` and your
handler continues, so a failing indicator never costs the reply after it.

## Receive media

```ts theme={null}
bot.onType('image', async (ctx) => { await ctx.reply('📸 Got your image!') })
bot.onType('audio', async (ctx) => { await ctx.reply('🎵 Got your audio') })
bot.onType('video', async (ctx) => { await ctx.reply('🎬 Got your video') })
bot.onType('sticker', async (ctx) => { await ctx.reply('🎨 Got your sticker!') })
bot.onType('document', async (ctx) => { await ctx.reply('📄 Got your file') })
bot.onType('location', async (ctx) => { await ctx.reply('📍 Got your location') })
bot.onType('contact', async (ctx) => { await ctx.reply('👤 Got your contact') })
bot.onType('poll', async (ctx) => { await ctx.reply('📊 Got your poll') })
```

`bot.onType(...)` handlers are low-specificity matchers (the old
`onImage`/`onAudio`/… helpers are deprecated aliases): a more specific
`bot.when(...)` that also matches wins first, unless it calls `ctx.pass()`.
The same matchers are available as trigger constants (`IMAGE`, `VOICE_NOTE`,
`LOCATION`, `CALL`...; multi-type ones such as `MEDIA` rank just below): see
[Special triggers](https://botmesh.afaya.com.co/guide/triggers.md).

## Receive a location

A shared location arrives as `ctx.type === 'location'` with its coordinates
normalized on `ctx.sharedLocation` (it is not `ctx.location`, which **sends**
one):

```ts theme={null}
bot.onType('location', async (ctx) => {
  const loc = ctx.sharedLocation
  if (!loc) return ctx.reply('I could not read that location')
  await ctx.reply(`📍 ${loc.latitude}, ${loc.longitude}${loc.name ? ` (${loc.name})` : ''}`)
})
```

`sharedLocation` is `{ latitude, longitude, name?, address?, live? }` (WGS84
decimal degrees; `live: true` on the first message of a live-location share).
`ctx.body` keeps the place name or address (or `''`), so text triggers still
work. Providers that fill it: WhatsApp via Baileys (pins and live locations),
WhatsApp Cloud API, Telegram (pins, live locations and venues), Messenger and
Instagram (when Meta sends coordinates), Evolution API and WPPConnect (pins and
live locations), Gupshup and Twilio (`Latitude`/`Longitude`). Discord and
HighLevel have no inbound locations. On other channels, or when the transport sends
no coordinates, `ctx.type` is still `'location'` but `sharedLocation` is
`undefined`. Provider authors map their payload with
`toInboundLocation({ latitude, longitude, name, address, live })` from
`@botmesh/core/spi`, which drops missing or out-of-range coordinates.

Inside a conversation, `c.waitLocation(opts?)` waits for one and resolves with
the `InboundLocation`; anything else re-prompts with `otherwise` (default: the
localized `core.wait.location`). The stored inbound message keeps it too
(`StoredMessage.location`).

```ts theme={null}
bot.conversation('delivery', async (c) => {
  await c.say('Share the delivery location 📍')
  const loc = await c.waitLocation({ timeout: '5m' })
  await c.say(`We deliver to ${loc.latitude}, ${loc.longitude}`)
})
```

## Poll votes

`ctx.poll(question, options, multiSelect?)` sends a native poll. Votes
arrive on the `poll` event with the full, current tally:

```ts theme={null}
await ctx.poll('Pizza or sushi?', ['Pizza', 'Sushi'])

bot.on('poll', (vote) => {
  // vote.ref.id      → the poll's message id
  // vote.ref.chatId  → the chat where it was voted (same chatId its messages use)
  // vote.senderId    → who just voted, with the canonical id of their messages
  // vote.options     → current tally: [{ name, votes, voters }]
  const picked = vote.options.filter((o) => o.voters?.includes(vote.senderId)).map((o) => o.name)
  console.log(picked.length ? `Picked: ${picked.join(', ')}` : 'Vote withdrawn')
})
```

On **Baileys**, WhatsApp encrypts every vote end to end; the provider
decrypts it itself. It remembers the 500 most recent polls it saw (the
bot's own and the ones others create in chats it listens to), only accepts
a vote that arrives in the poll's own chat and names its creator, keeps
each person's latest vote, and counts each vote message once (Baileys'
offline redelivery is not re-emitted). Limits: only polls seen by **this
process** can be decrypted (after a restart, votes on older polls are
dropped with a `debug` log), and the bot cannot vote. A poll someone else
creates still arrives as a `poll` message (`bot.onType('poll')`, the
[`POLL` trigger](https://botmesh.afaya.com.co/guide/triggers#message-type-triggers)); the encrypted vote no
longer does. Details in the `@botmesh/baileys` README.

On **Telegram**, polls are sent non-anonymous, so each vote arrives as a
`poll_answer` update and the provider tallies it into the same `poll` event
(empty selection = vote withdrawn). Telegram only reports votes on polls the
bot sent, and the provider remembers the 500 most recent ones it sent from this
process. Members of the chat can see who voted; `anonymousPolls: true` sends
anonymous polls, whose votes never reach the bot. A channel refuses public-voter
polls: the poll is retried once as anonymous and the channel is remembered.

## Access media content

Inbound media is downloaded **lazily**: the context only carries a reference
until you ask for the bytes with `loadMediaOf(ctx)`. It downloads once through
the provider and caches the result on `ctx.media`:

```ts theme={null}
import { loadMediaOf, MediaTooLargeError } from '@botmesh/core'

bot.onType('image', async (ctx) => {
  try {
    const media = await loadMediaOf(ctx, { maxBytes: 5 * 1024 * 1024 })
    if (!media) return                 // no media, or the download failed
    console.log(media.mimetype)        // 'image/jpeg'
    console.log(media.buffer)          // Buffer
    console.log(media.size)            // bytes, when the provider declared it
    // ctx.media is now the same payload
  } catch (err) {
    if (err instanceof MediaTooLargeError) await ctx.reply('That file is too large')
    else throw err
  }
})
```

* It resolves `undefined` when the message has no media, the provider cannot
  download, or (by default) the download fails. `{ throwOnError: true }`
  rejects with the download error instead.
* `maxBytes` is checked against the size the provider declared, before
  downloading anything, and again while reading; the download is cut as soon as
  it goes over. It rejects with `MediaTooLargeError` (a `PayloadTooLargeError`,
  with `bytes` and `maxBytes`).
* Call it in a `before('message')` hook to download before routing; the
  [voice](https://botmesh.afaya.com.co/guide/voice.md) plugin transcribes voice notes this way.

## The full `MessageContext`

Besides the reply helpers, every handler receives data fields on `ctx`:
`ctx.body`, `ctx.from` (chat id), `ctx.sender` (user id; = `from` in DMs,
participant in groups), `ctx.senderInfo`, `ctx.pushName`, `ctx.chat`
(`{ id, kind, name?, isGroup, isBroadcast, participants? }`), `ctx.type`,
`ctx.isGroup` / `ctx.isPrivate` / `ctx.isFromMe`, `ctx.mentions`,
`ctx.quoted?`, `ctx.media?` (after `loadMediaOf`), `ctx.timestamp` (when the message was
sent, in epoch **milliseconds** on every channel: `new Date(ctx.timestamp)`), `ctx.tools` (DI registry),
`ctx.raw` (provider-native payload), `ctx.ref` (the message reference —
`{ id, chatId, native? }`, replaces v1's `ctx.key`), `ctx.user` (the
sender's [persistent profile](https://botmesh.afaya.com.co/guide/user-data.md)), `ctx.handoff()` (pass the chat
to a [human](https://botmesh.afaya.com.co/guide/handoff.md)), `ctx.call` (the `CallEvent`, on
[`CALL`](https://botmesh.afaya.com.co/guide/triggers#call) contexts only) and the [i18n](https://botmesh.afaya.com.co/guide/i18n.md) members
`ctx.lang`, `ctx.langSource`, `ctx.t()` and `ctx.setLang()`.

## Identity per transport

Every provider resolves aliases before emitting, so `ctx.sender` is the
same for the same person (the identity contract):

* **Baileys:** LID and PN (phone) forms of a user are merged into one
  canonical id (the PN when known). Messages, reactions, poll votes, calls,
  group and presence events, `ctx.mentions` and the bot's own `selfId` all
  use it, so `groupMode: 'mentions'` works whether the mention used the LID
  or the PN.
* **Cloud API (`@botmesh/meta`):** users who turned on a WhatsApp
  **username** may arrive without a phone number, only with a
  business-scoped user id (**BSUID**, e.g. `US.13491208655302741918`). The
  provider keeps one stable id per person (the phone when known, otherwise
  the BSUID), learns phone ↔ BSUID pairs (persist them with an
  `identityMap`), and sends to a BSUID through the `recipient` field
  automatically. `senderInfo.username` and
  `senderInfo.platformData.bsuid` expose the details. Authentication
  templates require a phone: sending one to a BSUID fails with Graph error
  `131062`, mapped to `UnsupportedContentError`. See the `@botmesh/meta`
  README.

## Sending outside a handler

To message someone without waiting for them to write first — e.g. from a
[scheduled task](https://botmesh.afaya.com.co/guide/scheduler.md) — use the bot instance directly. Content is
the transport-neutral tagged union (`{ kind, ... }`):

```ts theme={null}
await bot.send('573001234567@s.whatsapp.net', '🌅 Good morning!')
await bot.send('573001234567@s.whatsapp.net', {
  kind: 'image',
  media: { url },
  caption: 'hi',
})
await bot.send(chatId, 'Hi, I am Ana from support')  // same path; a string is sent as text
```

## Broadcasts

`bot.broadcast(chatIds, content, options?)` sends one content (a string is sent as
text) to many chats, in batches, without blocking the per-chat inbound queues:

```ts theme={null}
const ac = new AbortController()
const res = await bot.broadcast(chatIds, '🎉 New promo!', {
  batchSize: 10,          // chats per batch (default 10)
  delayBetween: '2s',     // pause between batches (ms or '2s', '1m'; default 0)
  concurrency: 1,         // parallel sends per batch (default 1)
  retries: 3,             // retries per chat after a RateLimitedError (default 3)
  onProgress: (done, total) => bot.logger.info(`${done}/${total}`),
  onFail: (chatId, error) => bot.logger.warn({ chatId, err: error }, 'broadcast failed'),
  signal: ac.signal,      // abort to stop early
})
// → { total, sent, failed, skipped, errors: [{ chatId, error }], messages, cancelled, pending }
```

* A `RateLimitedError` pauses **every** worker for its `retryAfterMs` and retries that chat.
* Other errors are recorded in `errors` and the broadcast goes on; it never rejects for a per-chat failure.
* Aborting `signal` or calling `bot.stop()` starts no new send; chats never attempted come back in `pending` (`cancelled: true`).
* Duplicate ids are sent once; sends skipped by a `before('send')` hook count as `skipped`.

> [!WARNING]
>
> **Breaking change**
>
> `sent` and `failed` are now **counts**. The accepted sends moved to `messages` and the failures to `errors`.

From inside a handler, launch it with `void bot.broadcast(...)` so that chat's queue is not held while it runs.

## Starting a flow programmatically

`bot.trigger(name, { chatId, userId?, isGroup?, data?, ifActive?, body? })` starts a
registered flow or `bot.conversation` for someone who has not written: reminders,
webhooks, admin panels. `data` seeds `c.state`:

```ts theme={null}
bot.schedule('0 9 * * *', async () => {
  for (const appt of await db.appointmentsToday()) {
    await bot.trigger('reminder', { chatId: appt.chatId, data: { time: appt.time } })
  }
})
```

It goes through the access lists, the human handoff gate and the chat's FIFO queue.
It resolves `{ started, name, chatId, userId, reason? }` as soon as the conversation is
**entered**, not when it ends (listen to `'conversation:ended'` for that). `reason` is
`'stopped' | 'blocked' | 'handoff' | 'busy'`; an unknown `name` rejects.
A subject with a live or dormant conversation is left alone unless `ifActive: 'replace'`.
It is safe to `await bot.trigger(...)` from a handler or command of the same chat: it runs in that message's queue slot.

* `userId` (default: `chatId`) and `isGroup` target one participant of a group;
  `body` sets the synthetic message's `ctx.body` (default `''`).
* No `before('message')` hooks, matchers or `'message'` event run, and nothing is
  persisted for the synthetic message (`ctx.raw` is `{ synthetic: 'trigger', name }`).
* `bot.triggerFlow(id, { chatId, userId?, isGroup? }, { data?, body? })` is deprecated:
  it is `bot.trigger(id, { chatId, userId, isGroup, data, body, ifActive: 'replace' })`
  resolving `result.started`.

## Runtime metrics

`bot.metrics()`, `bot.resetMetrics()`, the Prometheus exporter `toPrometheus()` and
the `'conversation:started'` / `'conversation:ended'` events are documented in
[Operations](https://botmesh.afaya.com.co/guide/operations.md#runtime-metrics).

## Next steps

* [Conversation flows](https://botmesh.afaya.com.co/guide/flows.md) — multi-step conversations
* [Streaming](https://botmesh.afaya.com.co/guide/streaming.md) — long and AI replies, piece by piece
* [Channels](https://botmesh.afaya.com.co/guide/channels.md) — what each transport supports
* [Operations](https://botmesh.afaya.com.co/guide/operations.md) — metrics and conversation events
* [Examples](https://botmesh.afaya.com.co/examples/media.md) — full media recipes


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