Skip to main content

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):
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). 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.
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

Send video

Send audio & voice

Send file

Send sticker

Send location

Send contact

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) or a real poll usually reads better.
  • @botmesh/meta (Cloud API) — native interactive buttons/lists/templates.
  • @botmesh/telegram — native inline keyboards.
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.
  • 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.

Edit a 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) and update the outbound ledger.

React

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':

Other actions

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

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.

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):
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).

Poll votes

ctx.poll(question, options, multiSelect?) sends a native poll. Votes arrive on the poll event with the full, current tally:
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); 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:
  • 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 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), ctx.handoff() (pass the chat to a human), ctx.call (the CallEvent, on CALL contexts only) and the i18n 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 — use the bot instance directly. Content is the transport-neutral tagged union ({ kind, ... }):

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:
  • 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.
Breaking changesent 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:
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.

Next steps