Skip to content

Core Concepts ​

Understand how the Dispatcher decides what happens to each incoming message, and the pieces you compose a bot from.

The inbound pipeline ​

Every incoming message goes through the same pipeline, in this order. The first four steps run in the Bot as soon as the provider emits the event; the rest run inside the Dispatcher, on a FIFO queue per chat (two messages from the same chat are never processed in parallel). The first thing that consumes the message wins and stops the routing:

flowchart TD
    A[Message in] --> ACL{"Blacklisted /<br/>not whitelisted?"}
    ACL -->|Yes| X1[Dropped: not persisted, no hooks,<br/>not recorded as seen]
    ACL -->|No| DD{"Already seen?<br/>(dedupe: chatId + id)"}
    DD -->|Yes| X0[Dropped]
    DD -->|No| P[Persist chat, contact, message]
    P --> GM{"groupMode filter"}
    GM -->|Ignored| X2[Stops here]
    GM -->|OK| L["Resolve ctx.lang (i18n)"]
    L --> B["before('message') hooks<br/>(ctx.lang re-resolved if a hook rewrote ctx.body)"]
    B --> HO{"Chat handed off<br/>to a human?"}
    HO -->|"Yes (except allowCommands)"| J
    HO -->|No| C{"Command? e.g. /reset"}
    C -->|Yes| D[Command handler]
    C -->|No| E{Active conversation<br/>for this participant?}
    E -->|Yes| F[Conversation / flow step continues]
    E -->|No| R{Dormant persistent-flow<br/>checkpoint?}
    R -->|Yes| F
    R -->|No| G{"Matchers by specificity<br/>(WELCOME first)"}
    G -->|Yes| H[Handler runs]
    G -->|"No match / ctx.pass()"| I[onMessage catch-all]
    D --> J["after('message') hooks"]
    F --> J
    H --> J
    I --> J
    J --> K["'message' event emitted"]
  1. Blacklist / whitelist: a blocked sender or chat is dropped silently, before anything else. It is not persisted, no hooks run, no 'message' event fires and it is not recorded as seen by dedupe, so a redelivery after un-blocking is processed. See Handoff & access lists.
  2. Inbound dedupe: a redelivered message or interaction (same chatId
    • id, from webhook retries or a Baileys offline resync) is dropped. On by default; see Inbound dedupe. With durable processing on (opt-in), the event is then written to the inbox journal before anything else runs, acknowledged once its conversation steps settled, and replayed from step 1 after a crash.
  3. Persistence: the chat, contact and message are upserted in the store.
  4. groupMode filter: a group message the bot must ignore stops here (it is still persisted).
  5. Language resolution, then before('message') hooks: when i18n is enabled, ctx.lang is resolved first (stored ctx.user preference → provider-reported language → detect → default), so hooks and handlers already see it (ctx.langSource says which one won). Then the hooks run and may abort() the message. If a hook rewrote ctx.body (e.g. the @botmesh/voice transcript) and the language came from detect or the default, it is resolved again so detect sees the final text (a ctx.lang a hook set itself is kept).
  6. Handoff gate: while a chat is handed off to a human, steps 7–11 are skipped (except the commands in handoff.allowCommands). The message is still persisted, hooks still run and 'message' still fires.
  7. Commands (/reset, /help, ...) always win, even over an active conversation. That's what lets /reset escape a stuck flow.
  8. Active conversation: if this participant already started a flow or bot.conversation that's waiting for a reply, the message goes there.
  9. Dormant resume: if a .persistent() flow was checkpointed in the store (e.g. before a restart), the flow resumes at the saved step.
  10. Matchers registered with bot.when(...), tried by specificity: WELCOME (first contact only), then text triggers (an exact string/equals match beats startsWith, a regex or contains), then predicate functions, then single-type triggers (IMAGE, onImage...), then multi-type triggers (MEDIA). With equal specificity, the one registered first wins.
  11. bot.onMessage(): the catch-all (also aiPlugin). It runs only if nothing above consumed the message.
  12. Finish: after('message') hooks, then the 'message' event (bot.onIdle timers re-arm here).

When the bot runs as one of several replicas (coordination), one more step sits between 4 and 5: the message took a ticket in its chat's cross-replica FIFO when it was queued, and waits there for the chat's turn. Only one replica holds a chat's turn at a time and turns follow ingress order, so the FIFO per chat holds across replicas. A message whose turn cannot be had is dropped and reported as 'error' (its dedupe id is released). The turn is released once the conversation the message fed is waiting again or has ended.

Incoming calls take a separate path: the access-list and handoff gates come first and block a call completely (no 'call' hooks, no 'call' event, no CALL trigger; it counts in metrics().dropped). An admitted call runs the before/after('call') hooks and the 'call' event, then the same language resolution and only CALL triggers. No message hooks, commands or 'message' event.

bot.trigger(name, { chatId }) (start a flow without an inbound message; the deprecated bot.triggerFlow() is a thin wrapper over it with ifActive: 'replace') takes its own path too: the access lists, then the chat's FIFO slot, the handoff gate, the language resolution and the conversation start. No dedupe, persistence, message hooks, matchers or 'message' event.

If a handler throws, the onError('message') hooks run and the error is always emitted on bot.on('error', ({ error, ctx }) => ...), even when no error hook is registered.

Handlers consume by default: once one runs, the pipeline stops there. Call ctx.pass() inside a handler to yield to the next matching handler instead of stopping — this is how low-specificity handlers like onImage/ onType cooperate with a broader when:

ts
bot.when(/.+/, async (ctx) => {
  if (ctx.type === 'image') { ctx.pass(); return } // yield to the next handler
  await ctx.reply('text received')
})

bot.onType('image', async (ctx) => {
  await ctx.reply('image received') // now reached, because the handler above passed
})

1. Bot + Provider — the center ​

ts
import { Bot } from '@botmesh/core'
import { BaileysProvider } from '@botmesh/baileys'

const bot = new Bot({ provider: new BaileysProvider({ session: 'my-bot' }) })
await bot.start()

The Bot (from @botmesh/core) is the transport-agnostic engine: it runs the Dispatcher, hooks, flows, conversations and the scheduler. The provider is the transport boundary — @botmesh/baileys connects to WhatsApp; @botmesh/meta (official Cloud API), @botmesh/telegram and the experimental Messenger, Instagram and Discord transports plug into the same engine. All transports feed the bot the same normalized events.

Sends need a running bot: before bot.start() and after bot.stop(), bot.send(), ctx.reply() and ctx.delete() reject with BotNotRunningError and nothing reaches the provider (a 'ready' listener may already send). Presence hints (ctx.typing(), ctx.markRead(), the automatic typing indicator) are best-effort instead: while the transport is disconnected or stopped they are skipped without error, logged at debug and counted in metrics().presenceSkipped. Concurrent start() / stop() calls run one at a time, in call order; each stop() of a running bot emits 'stopping' before the drain and the transport stop, then 'stopped'. An event that breaks the provider contract is dropped and reported on 'error' as an InvalidProviderEventError; an event of a kind the core does not know (a newer provider) is ignored.

2. when — simple responses ​

ts
bot.when('hola', async (ctx) => {
  await ctx.reply('👋 Hello!')
})

When the message matches the trigger, run this function. The default match strategy is normalized equals: lowercased, accents stripped, spaces collapsed — 'hola' matches "hola"/"Hóla ", but NOT "/hola" or "hola amigo". Triggers accept text, a list, a regex, a predicate, or an explicit { pattern, match, scope } object:

ts
bot.when('hola', handler)
bot.when(['bye', 'goodbye'], handler)
bot.when(/\d{4,}/, handler)
bot.when({ pattern: 'factura', match: 'contains' }, handler) // v1-style "contains"

3. flow / conversation — multi-step conversations ​

ts
const register = flow('registrar')
  .ask('Name?', 'name')
  .say((_ctx, c) => `Hi ${c.state.name}`)

bot.use(register)

A flow compiles to a real async conversation: the bot asks, the user answers, c.state stores it and the flow continues — with validation, retries, branching (choose) and timeouts. For full manual control you can skip the DSL and write bot.conversation(name, async (c, ctx) => {...}) directly.

4. Hooks — middleware (before / after / onError) ​

ts
bot.before('message', async (ctx, next) => {
  console.log('📥', ctx.from, ctx.body)
  await next()
})

bot.after('message', async (ctx) => {
  console.log('📤 processed')
})

bot.onError('message', async (ctx, error) => {
  console.error('💥 error:', error)
})

Hooks run before or after each message, like Express middleware. 'message' is the main hook event; 'send' fires on every outbound send. A before hook that returns without calling next() stops the event, like ctx.abort().

5. Plugins — packaged functionality ​

ts
bot.plug(slashCommandsPlugin, { greeting: 'Hi!' })

A plugin is a reusable package that can register hooks, handlers and tools. bot.plug() returns an unplug() function that reverts what it registered through ctx.bot (handlers, hooks, nested plugins, runtime flows and onDispose callbacks; listeners, tools, locales and scheduled jobs need an onDispose, see Plugins).

A plugin is plugged once per bot: plugging a plugin whose name is already plugged throws, unless every instance sets multiple: true in its spec.

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 consumed as the answer to the next question of a conversation. The Bot drops redeliveries before they reach any handler. It is on by default:

ts
new Bot({ provider })                                     // default: { size: 1000 }, no TTL
new Bot({ provider, dedupe: { size: 5000, ttl: '1h' } })  // tune it
new Bot({ provider, dedupe: false })                      // turn it off
  • A message is keyed by chatId + ref.id, because some transports only make ids unique per chat (e.g. Telegram message_id). An interaction (button or list tap) is keyed by its interactionId, so two taps on the same menu are two events.
  • Edits: the same id with a different body (Telegram edited_message) is processed; an identical retry is dropped. Only a hash of the body is kept, never the text.
  • Events with an empty id are never deduplicated.
  • The access lists run first: a message or interaction from a blocked sender or chat is never recorded, so the same id redelivered after un-blocking is processed (and counts as blocked, not duplicate, while still blocked).
  • If persistence fails before the handlers run (store down), the key is released so the transport's retry is processed.

Echo filter ​

Every provider marks its own messages with fromMe, and the core drops them before anything else. A few transports only learn their own id after a handshake, or a mailbox/gateway can hand back a message the bot just sent without that mark. echoFilter complements fromMe (it does not replace it): the bot remembers the refs of its successful sends and drops an inbound message whose ref is remembered, before the access lists, dedupe and the journal, so an echo never counts as incoming traffic.

ts
new Bot({ provider, echoFilter: true })                       // 1000 refs, no TTL
new Bot({ provider, echoFilter: { size: 5000, ttl: '10m' } }) // tune it

Drops count in metrics().dropped.echo (Prometheus: botmesh_dropped_total{reason="echo"}) and only when the option is on; without it the bot behaves exactly as before. The ring is in memory (per process) and each remembered ref is consumed by the first matching echo; a later redelivery is the deduper's business.

  • A message still queued behind its chat's earlier messages when bot.stop() runs is dropped unprocessed and its key is released, so the same id redelivered after bot.start() is processed.
  • size is an LRU; ttl (ms or '30s'/'10m'/'1h') counts from the first acceptance.
  • Limit: it lives in process memory. It does not deduplicate across replicas or after a restart.

Providers must give each inbound event an id that names exactly one logical event, stays the same when it is redelivered, and is '' when the transport cannot guarantee that. This is the inbound id contract, documented on IProvider. The deduper itself (InboundDeduper, inboundDedupeKey…) is an engine internal (@botmesh/core/internal).

'message' event vs onMessage() ​

These look similar but are not the same:

  • bot.on('message', ctx => ...) fires for every message the Dispatcher finished processing — including ones a command, an active conversation, or a when handler already consumed.
  • bot.onMessage(ctx => ...) is the catch-all — it only runs when nothing else consumed the message.

Group conversations are per-participant by default ​

In a group, two different participants talking to the bot in the same chat get independent conversations by default (keyed internally by ${from}:${sender}). Use conversationScope: 'chat' if you want a single shared conversation per group chat instead:

ts
const bot = new Bot({
  provider: new BaileysProvider({ session: 'my-bot' }),
  conversationScope: 'chat',
})

The ctx object — your best friend ​

ts
bot.when('hola', async (ctx) => {
  console.log(ctx.body)        // message text
  console.log(ctx.from)        // chat id (opaque; a JID on WhatsApp/Baileys)
  console.log(ctx.sender)      // sender id (= from in DMs, participant in groups)
  console.log(ctx.type)        // 'text', 'image', 'audio', ...
  console.log(ctx.isGroup)     // is it a group?

  await ctx.reply('text')
  await ctx.photo(url, 'caption')
  await ctx.poll('Question?', ['A', 'B'])
  await ctx.react('👍')

  const db = ctx.tools.db  // injected tool
  await ctx.user.set('lang', 'en')   // persistent per-user profile
})

Chat and sender ids ​

ctx.from (chat) and ctx.sender (user) are opaque strings the provider resolves — the core never sniffs their shape. For the Baileys transport they happen to be WhatsApp JIDs (Jabber IDs):

TypeFormatExample
Usernumber@s.whatsapp.net573001234567@s.whatsapp.net
Groupid@g.us120363xxx@g.us
Newsletterid@newsletter120363xxx@newsletter

You don't need to memorize this — ctx.from gives you the correct id on every transport. If you need the transport-native key of the inbound message (to quote/edit/delete it with provider calls), use ctx.ref ({ id, chatId, native? }).

Durations ​

Every duration option takes a duration string ('500ms', '30s', '2m', '1h', '1d', '1w', '5 minutes') or a number of milliseconds. That includes every option whose name ends in Ms: typing.minMs, durability.inbox.backoff.baseMs, coordination.turnWaitMs, scheduler.tickMs and the rest.

ts
const bot = new Bot({
  provider,
  typing: { minMs: '800ms', maxMs: '3s' },
  durability: { pollMs: '1s', outbox: { inlineMaxMs: '15s', backoff: { baseMs: '1s', capMs: '1m' } } },
})

new Bot() checks every duration option of BotConfig. An invalid value throws a ConfigValidationError whose field is the option's path, so the message tells you which option to fix:

text
ConfigValidationError: Invalid config.durability.pollMs: expected a duration ('500ms', '30s', '10m', '1h') or milliseconds, got string ("soon")

parseDuration() exposes the same grammar.

Logger ​

BotConfig.logger (and the logger option of every provider and store) takes a LoggerLike: pino, console, or any object with debug, info, warn and error. The packages always call it pino style, so a custom logger only has to handle two shapes:

ts
logger.warn('provider reconnected')                  // a message
logger.error({ err, chatId }, 'dispatch error')      // context object first, then the message

An error always goes under the err key. The packages never put an object after the message (pino would drop it), and child(bindings) is optional.

Package entries ​

Import fromFor
@botmesh/coreApplications: the Bot, the flow DSL, the message types, the options and the errors you can catch.
@botmesh/core/testingTests: createTestBot, MockProvider, the chaos kit, golden transcripts, crash simulation and MemoryCoordinator.
@botmesh/core/spiAuthors of stores, durability backends, schedule stores, coordinators, tracer integrations and transports: KvDurability, MemoryKv, MemoryScheduleStore, messageKey, toInboundLocation, the record types… Covered by semver.
@botmesh/core/devThe botmesh dev terminal simulator.
@botmesh/core/internalEngine classes (Dispatcher, ConversationRuntime, HookSystem…). No semver guarantee: do not use it from an application.

Next steps ​

Released under the MIT License.