The building blocks
Bot and provider
Bot (from @botmesh/core) is the transport-agnostic engine: it runs the
dispatcher, hooks, flows, conversations and the scheduler. The provider is
the transport: @botmesh/baileys (WhatsApp Web), @botmesh/meta (WhatsApp
Cloud API), @botmesh/telegram and the other channels all feed
the bot the same normalized events, so the code you register works on any of
them. Every option of new Bot() is in the
configuration reference.
when: simple responses
'hello' matches “Hello” and ” héllo ”, but not “/hello” or “hello there”.
Triggers take other forms too:
bot.command('help', handler) registers a /help command (the prefix is
commandPrefix), and bot.onMessage(handler) is the catch-all for whatever
nothing else handled.
flow and conversation: multi-step conversations
c.state keeps the answer and the flow continues, with
validation, retries, menus and timeouts. It compiles to the same runtime as
bot.conversation(name, async (c, ctx) => { ... }), which you write by hand
when you need loops or arbitrary logic. See Conversation flows.
Hooks: middleware
before hooks run before routing and can stop or rewrite a message; after
hooks run once it was handled; onError hooks run when a handler throws.
'send' hooks see every outgoing message. See Hooks.
Plugins: packaged features
bot.plug() returns an unplug()
function that reverts what the plugin registered. A plugin name can be
plugged once per bot unless its spec sets multiple: true. See
Plugins.
The ctx object
Every handler receives a MessageContext. What it tells you:
What it does:
Every send helper is covered in Messages & media.
Chat and sender ids
ctx.from (chat) and ctx.sender (user) are opaque strings the provider
chooses; the core never parses them. Store and compare them as they arrive.
On WhatsApp (Baileys) they are JIDs:
Telegram uses numeric chat ids, the Cloud API phone numbers, and so on. The
provider also resolves aliases: the same person always arrives with the same
ctx.sender, even when WhatsApp switches between a phone number and a LID.
The inbound pipeline
Every incoming message goes through the same steps, in this order. The first four run as soon as the provider emits the event; the rest run on a FIFO queue per chat, so two messages from the same chat are never processed at the same time. The first handler that consumes the message stops the routing: Before step 1 the bot drops its own messages (fromMe) and, when
echoFilter is on, echoes of its own sends.
- Blacklist / whitelist: a blocked sender or chat is dropped silently. It
is not persisted, no hooks run, no
'message'event fires and it is not recorded as seen, so a redelivery after un-blocking is processed. See Handoff & access lists. - Inbound dedupe: a redelivered message (same
chatId+ id, from a webhook retry or a reconnect) is dropped. See Inbound dedupe. With durable processing on, the event is then written to the inbox journal, so it is replayed from step 1 after a crash. - Persistence: the chat, contact and message are saved in the store.
groupModefilter: a group message the bot must ignore stops here (it is still persisted).- Language, then
before('message')hooks: with i18n on,ctx.langis resolved first, so hooks and handlers already see it. Then the hooks run and mayabort()the message. If a hook rewrotectx.body(the@botmesh/voicetranscript, for instance) and the language came fromdetector the default, it is resolved again on the new text. - Handoff gate: while a chat is handed off to a human, steps
7–11 are skipped (except
handoff.allowCommands). The message is still persisted, hooks still run and'message'still fires. - Commands (
/reset,/help…) always win, even over an active conversation. That is what lets/resetescape a stuck flow. - Active conversation: if this participant is in a flow or
bot.conversationwaiting for a reply, the message goes there. - Dormant resume: a
.persistent()flow checkpointed in the store (for example before a restart) resumes at the saved step. - Matchers registered with
bot.when()andbot.onType(), by specificity:WELCOME(first contact only), then exact text,startsWith,endsWith, regex,contains, predicates, single types (IMAGE) and multi-type triggers (MEDIA). With equal specificity, the one registered first wins. See Precedence. bot.onMessage(): the catch-all (aiPluginis one). It runs only if nothing above consumed the message.- Finish:
after('message')hooks, then the'message'event (bot.onIdletimers re-arm here).
onError('message') hooks run and the error is
emitted on bot.on('error', ({ error, ctx }) => ...), whether or not an error
hook exists.
Handlers consume by default
Once a handler runs, routing stops there. Callctx.pass() to hand the
message to the next matching handler instead:
'message' event vs onMessage()
bot.on('message', ctx => ...)fires for every message the dispatcher finished, including ones a command, a conversation or awhenhandler consumed. Use it for logging and inbox integrations.bot.onMessage(ctx => ...)is the catch-all handler: it runs only when nothing else consumed the message.
Calls, bot.trigger() and replicas
- Calls take a shorter path: the access-list and handoff gates come
first and block a call completely (no
'call'hooks, event orCALLtrigger; counted inmetrics().dropped). An admitted call runs thebefore/after('call')hooks and the'call'event, then onlyCALLtriggers: no message hooks, commands or'message'event. bot.trigger(name, { chatId })starts a flow without an inbound message (see Starting a flow programmatically). It checks the access lists, waits for the chat’s FIFO slot, checks the handoff gate, resolves the language and starts the conversation. No dedupe, persistence, message hooks, matchers or'message'event.- With
coordination(several replicas), one more step sits between 4 and 5: the message waits for its chat’s turn, so the FIFO per chat holds across replicas. A message whose turn cannot be had is dropped and reported on'error'(its dedupe id is released, so a redelivery is processed).
Group conversations are per participant
In a group, two participants talking to the bot get independent conversations by default: one can be halfway through a sign-up while the other asks for the menu. SetconversationScope: 'chat' for a single shared
conversation per group:
groupMode and the 'message' event stay per chat either way.
Inbound dedupe
Webhook transports (Cloud API, Messenger, Instagram, Telegram webhook) retry a delivery when they don’t get a 2xx in time, and Baileys can redeliver recent messages after a reconnect. Without protection the same message would start a flow twice, or be taken as the answer to the next question. The bot drops redeliveries before any handler sees them. It is on by default:- A message is keyed by
chatId+ref.id(some transports only make ids unique per chat). A button or list tap is keyed by itsinteractionId, so two taps on the same menu are two events. - Edits: the same id with a different body (a Telegram edit) is processed; an identical retry is dropped. Only a hash of the body is kept.
- Events with an empty id are never deduplicated.
- A blocked message is never recorded, so the same id redelivered after un-blocking is processed.
- If persistence fails before the handlers run (store down), or the message is
still queued when
bot.stop()runs, the key is released so a redelivery is processed. sizebounds an LRU;ttl(a duration) counts from the first acceptance.- Limit: it lives in process memory, so it does not deduplicate across replicas or after a restart. Durable processing covers that.
'' when the transport cannot
guarantee that (the inbound id contract on IProvider).
Echo filter
Every provider marks the bot’s own messages withfromMe, and the core drops
them first. A few transports only learn their own id after a handshake, and a
mailbox or gateway can hand back a message the bot just sent without that mark.
echoFilter adds a second guard: the bot remembers the refs of its successful
sends and drops an inbound message carrying one of them, before the access
lists, dedupe and the journal.
metrics().dropped.echo (Prometheus:
botmesh_inbound_dropped_total{reason="echo"}). The memory is per process, and each
remembered ref is consumed by the first echo that matches it.
Package entries
Next steps
- Configuration reference: every
new Bot()option, events, logging and errors - Messages & media: all reply types
- Conversation flows: multi-step conversations
- Special triggers: WELCOME, message types and CALL
- Hooks: middleware
- Examples: practical recipes