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):
await ctx.reply('👋 Hello!')await ctx.quote('replying to your message') // quotes the inbound messageawait 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).
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).
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.
const sent = await ctx.reply(longAnswer) // 9000 chars on Telegram → 3 messagessent.id // the last partsent.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.
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.
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 selectawait 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.
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.
const msg = await ctx.reply('Processing…')await ctx.edit('Done ✅') // edits this context's own last replyawait ctx.edit(msg.ref, 'Done ✅') // edits a given messageawait 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) and update the outbound ledger.
await ctx.react('👍')await ctx.react('❤️')await ctx.react('🔥', msg.ref) // react to another messageawait ctx.react('') // remove the reaction, where the transport allows it
On providers that cannot send reactions, ctx.react resolves { skipped: true }
instead of rejecting.
await ctx.markRead() // mark the inbound message as readawait 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.
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.
A shared location arrives as ctx.type === 'location' with its coordinates
normalized on ctx.sharedLocation (it is not ctx.location, which sends
one):
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).
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}`)})
ctx.poll(question, options, multiSelect?) sends a native poll. Votes
arrive on the poll event with the full, current tally:
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); 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.
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:
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 plugin transcribes voice notes this way.
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().
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.
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, ... }):
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
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:
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.
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.
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.
bot.metrics(), bot.resetMetrics(), the Prometheus exporter toPrometheus() and
the 'conversation:started' / 'conversation:ended' events are documented in
Operations.