Skip to main content
Public packagesThe packages on this page are published on npm and covered by semver from their stable release (1.0.0). Each one keeps its building blocks in a /internal entry with no semver guarantee (@botmesh/nlu has none).
All of them are regular plugins with a synchronous install (bot.plug(factory({...})); nluPlugin is a spec, bot.plug(nluPlugin, config)). Importing a package types its ctx.tools.* entry; outside a handler, use bot.injectTools.<name>.
Install only the ones you use.

How they fit in the pipeline

Three of them hook before('message'). Higher priority runs first, so the order does not depend on the order you call bot.plug(): What follows from that:
  • Chatwoot sees messages that moderation later drops (the agent gets the full picture).
  • A message moderation drops never reaches Dialogflow, commands or handlers.
  • before('message') hooks keep running while a chat is in handoff, so moderation and Chatwoot keep working there. Dialogflow’s hook skips handed-off chats: no detectIntent call is made while a human is in charge (see Dialogflow).
  • Dialogflow routes are bot.when predicates, so they win over the aiPlugin catch-all whatever the plug order. The rule “install aiPlugin after slashCommandsPlugin” still applies; these plugins do not change it.

Chatwoot

Bridges the bot with an API channel inbox in Chatwoot, on top of the core handoff API.
With Express or Fastify, pass the raw body:

What it does

Ignored webhooks: private notes, incoming messages, agent-bot messages, other inboxes, unknown conversations and echoes of messages the plugin created itself. Each chat maps to one Chatwoot contact (identifier = chat id) and one conversation. The mapping lives in the bot store through the cursor API (chatwoot:chat:<chatId>, chatwoot:conv:<id>), so it survives restarts with a persistent store. Chat ids are only used as opaque keys. If the mapping is lost (a new store, or a restart on MemoryStore), the plugin finds the contact by exact identifier (contacts filter API) and keeps using its newest conversation in the inbox, so agents answering there still reach the chat. One chatwootPlugin() instance serves one bot: plugging the same instance into a second bot throws (create one per bot, each with its webhook URL).

Chatwoot options

ctx.tools.chatwoot (also the object returned by chatwootPlugin()): handleWebhook(), webhookHandler({ maxBytes }), conversationOf(chatId), chatOf(conversationId), addLabels(chatId, labels), removeLabels(chatId, labels), flush() (waits for pending sync, useful on shutdown and in tests) and client (ChatwootClient).

Security

  • Webhook auth is mandatory: the factory throws without webhookToken, webhookSecret or insecureNoWebhookAuth: true. Keep the last one for an endpoint that is not reachable from outside.
  • The token is accepted in ?token=, x-chatwoot-token or Authorization: Bearer. Chatwoot itself (checked on 4.7) does not sign account webhooks, so put the token in the webhook URL; webhookSecret is for a sender or proxy that signs as described next. The HMAC-SHA256 signature goes in x-chatwoot-signature (hex, sha256= prefix allowed) and is computed over ${timestamp}.${rawBody}, with x-chatwoot-timestamp (seconds) within webhookToleranceSec (300). A signature over the bare body never expires, so a captured request could be replayed: it is rejected unless you set webhookAllowUntimedSignature: true. Comparisons are constant-time.
  • handleWebhook answers 401 without valid auth, 400 on invalid JSON and 503 before the plugin is installed; it never throws. webhookHandler only accepts POST and caps the body at 1 MiB.
  • Outgoing HTTP goes through secureFetch from @botmesh/http: HTTPS only, SSRF guard, timeouts and no redirects, so api_access_token never reaches another origin. A self-hosted Chatwoot on a LAN needs http.allowPrivateHosts (and http.allowHttp for plain HTTP): an http:// baseUrl without http.allowHttp throws when the plugin is created, and a blocked private host is reported with the option to set.
  • With syncMode: 'all' every conversation is copied to Chatwoot. Use syncMode: 'handoff' if only escalated chats should leave the bot.

Chatwoot limitations

  • Inbound media is mirrored as text ([image] caption), not uploaded.
  • One chat, one conversation. If the mapped conversation is gone (Chatwoot answers 404, or 422 while it is still deleting a contact), a new one is created and the message goes there.
  • Marking a refused agent reply as failed uses the message status API, which Chatwoot only offers for API-channel inboxes (the kind this plugin needs anyway).
  • Without cursor support in the store the mapping lives in memory (a warning is logged at install).

Tickets

Support tickets and a queue as ctx.tools.tickets.

API

Statuses: open (queued), assigned, pending (waiting on the customer, out of the queue) and closed. Ids are sequential strings ('1', '2'…).

Ticket options

Templates accept {{id}}, {{position}}, {{subject}}, {{priority}}, {{assignee}}, {{status}} and {{reason}}.

Persistence

By default tickets are stored through the store cursor API (tickets:seq, tickets:index, tickets:t:<id>), which all six stores implement; with a plain MemoryStore they live in RAM. Only non-closed tickets stay in memory; closed ones are read on demand. Data loads lazily on the first call and mutations are serialized: a failed store write rejects the call and leaves the desk unchanged. TicketDesk can also be used without a bot: new TicketDesk({ storage: memoryTicketStorage() }).

Ticket limitations

  • There is no agent UI or agent identity: assignee is a free string. Build the panel on bot.injectTools.tickets and protect it yourself.
  • With handoffOnAssign, only the first assignment hands the chat off: a reassignment keeps the handoff and its meta.assignee (read the ticket for the current agent), and unassigning (assignee: null) puts the ticket back in the queue but keeps the chat handed off. Call bot.resume(chatId) yourself if the bot should answer again.
  • meta must be a plain JSON object (no Date, Map, class instances, functions or BigInt); anything else is rejected before the ticket is stored. A property set to undefined is dropped, as in JSON, so { email: user?.email } or { ...prev, key: undefined } (to remove a key) work; undefined inside an array is rejected.
  • In shared mode every call re-reads the index and every active ticket, so its cost grows with the number of open tickets.
  • list() with a filter that may include closed scans every id from 1 to the last one.
  • The default storage (the store’s cursors) has no compare-and-set, so it is for one process per store namespace: two replicas over it would hand out the same id and overwrite each other’s index. For replicas, pass a storage with setIf, such as durableKvTicketStorage(kv) over the same DurableKv (Postgres, Redis, SQLite or a file) on every replica. The desk then re-reads the index on every call, claims ids with a compare-and-set and retries a lost write (maxRetries, default 20, then TicketContentionError). With reuseOpen (the default) it also claims the chat’s open ticket with a compare-and-set, so two replicas creating a ticket for the same chat at once (from a handler, the API or the manager) get the same one. A failed create never leaves an open ticket that get, list and queue cannot see.

Moderation

Group moderation that runs before any handler.
A message that breaks a rule runs its actions and is abort()ed: it does not reach commands, conversations or handlers. Messages from a muted user are dropped until the mute expires. By default only groups are moderated (applyTo: 'all' adds private chats). Interactions and the bot’s own messages are never moderated.

Rules and actions

  • Words are compared ignoring case and accents (ñ stays distinct from n) and as whole words; RegExps run on the raw text.
  • Invisible characters (zero-width, soft hyphen, bidi controls) are dropped and compatibility forms such as fullwidth spam are read as plain letters before words and links are matched, so they cannot hide a banned word or an invite. WhatsApp formatting around a link (_italic_, *bold*, ~strike~) does not hide it either.
  • Recognized invites: WhatsApp (chat.whatsapp.com/…), Telegram (t.me/+…, t.me/joinchat/…) and Discord (discord.gg/…, discord.com/invite/…). allowInvites matches the exact code.
  • action takes one action or several in order.
  • autoKickOnLink: true is shorthand for linkFilter: { action: ['delete', 'kick'] } and only applies when no linkFilter is set (globally or in the group override).
Warnings are counted per chat + user; at warnings.max the plugin runs warnings.then and resets the count (warnings: false disables it). Texts are configurable with messages: { warn, reasons } ({name}, {reason}, {count}, {max}); the built-in ones follow the chat language (English by default, Spanish with lang: 'es'). Admins skip every rule (adminBypass, default true): ids in admins, anyone isAdmin(chatId, userId) approves, and the list from provider.groups.getAdmins(chatId), cached for adminCacheMs (default 5 min) and invalidated on promote/demote group events. Admins are only looked up for a message that breaks a rule or comes from a muted user. If isAdmin throws, the sender counts as a non-admin (and a warning is logged); if getAdmins fails, the last known list is kept and the provider is asked again after at most 30 seconds. When someone leaves (remove), their warnings, mute and spam window are forgotten. The state stays bounded on a long-running bot: expired mutes and stale admin lists are swept, and at most maxTrackedUsers (default 10,000) warning counters are kept, dropping the least recently warned user’s. ctx.tools.moderation: mute, unmute, isMuted, warnings, resetWarnings, kick (resolves an ActionResult), setGroupRules (false disables, undefined removes the override) and invalidateAdmins. The config is validated when you call moderationPlugin() and by setGroupRules: an unknown action, a non-positive count or window, or a field of the wrong type throws a TypeError/RangeError naming the field. {name} in the warning text is the sender’s display name with control and bidi characters removed, on one line and at most 64 characters.
groupMode runs firstThe bot’s groupMode filter runs before before('message') hooks. With groupMode: 'mentions' or 'commands', group messages that do not mention the bot or are not commands never reach moderation. Use groupMode: 'all' (the default) to moderate the whole group; the plugin logs a warning at install when it sees a restrictive mode.

Moderation limitations

  • State (spam windows, warnings, mutes) lives in memory and is lost on restart.
  • No provider in the monorepo exposes groups.removeParticipants / getAdmins yet, so kick degrades to mute and admin detection relies on admins / isAdmin.
  • GroupAdminApi and ModerationProvider.groups are @experimental (outside semver) until @botmesh/core defines a groups capability for providers; their shape may change in a minor release.

Dialogflow

Runs every inbound text through Dialogflow ES or CX detectIntent and routes the intent to a handler or a flow. No Google SDK: the service-account JWT is signed with node:crypto (RS256) and all requests go through secureFetch.
Routes can also go in the config: routes: { 'book.table': bookingFlow, greeting: (ctx) => ctx.reply('Hi!') }. For CX, add cx: { agentId, location, environment? }.

How it works

  1. The before('message') hook calls detectIntent and stores the result for that message. ctx.tools.dialogflow.resultFor(ctx) reads it from any handler.
  2. whenDialogflowIntent registers a bot.when predicate that reads that result (no second call). If the intent matches with at least minConfidence, the target runs; otherwise the message falls through to other matchers and the catch-all.
  3. Not sent to Dialogflow: empty messages, interactions, commands (commandPrefix, default '/'), texts longer than maxTextLength (256 characters, the API limit), messages from a chat in handoff, messages from a user inside an active conversation (skipActiveConversation, default true) and anything filter rejects.
  4. If detectIntent (or filter) fails, it is logged, onError(error, ctx) is called and the message keeps flowing. A 401 drops the cached access token and retries once with a fresh one.
While an ES intent still has required parameters to fill, the result has allRequiredParamsPresent: false and the agent’s prompt in messages. A route runs anyway, so check that flag in the handler if the intent relies on Dialogflow’s slot filling. fulfillment: 'all' also answers fallback / no-match results with the agent’s text, so those messages never reach your onMessage or the aiPlugin catch-all. Keep 'off' or 'unrouted' if an LLM should answer what Dialogflow does not understand.

Dialogflow options

Security and privacy

  • Invalid credentials (code: 'INVALID_CREDENTIALS') and invalid options (code: 'INVALID_CONFIG': minConfidence outside 0–1, an unknown fulfillment or sessionScope, an empty commandPrefix, a malformed location…) throw DialogflowError synchronously at bot.plug(). The access token is fetched lazily on the first message, cached and refreshed shortly before it expires.
  • The session key is hashed (SHA-256, 32 characters) before it is used as the Dialogflow session id, so chat ids and phone numbers are not sent to Google. The message text is.
  • Keep the service-account JSON in a secret, never in the repo.
  • Parameters (in the result and copied into c.state) skip __proto__, constructor and prototype: they come from a remote service and must not reach the prototype of the state object.
  • A route target that is not a handler function, a flow() builder or a compiled flow throws a TypeError when it is registered.

Dialogflow limitations

  • Only text is sent (no audio, no events). CX form filling lives in pages: allRequiredParamsPresent is always true there.

Local intent matching (NLU)

@botmesh/nlu matches messages against intents you define with example phrases, entirely in-process: no network, no model, no extra dependency. It fits command-like intents (“where is my order”, “talk to a person”) in a bot that should not pay for an LLM or a Dialogflow agent on every message. For open-ended language, use the AI plugin, alone or behind it.
  • nluPlugin is a plugin spec: plug it as bot.plug(nluPlugin, config). It puts an IntentEngine in ctx.tools.nlu (and bot.injectTools.nlu).
  • whenIntent(bot, engine, intent, handler) is a bot.when() predicate: the handler runs only when the message’s best intent is that one, above the threshold, and receives the match. It returns the unsubscribe function.
  • engine.match(text) returns { intent, confidence, entities? } or null; engine.rank(text) scores every intent (useful to debug a phrase); engine.define(intent, utterances) adds phrases at runtime.
  • Matching normalizes case, accents, punctuation, full-width letters and invisible characters (¡HOLA! equals hola), then scores the words in common; vowel signs of scripts such as Devanagari stay in their word. A phrase with only emoji or punctuation throws in define(): it could never match. Punctuation splits words, so an id like A-1 does not fill one slot. An exact phrase, or a phrase with {slot} placeholders that lines up with the whole message, scores 1; a slot captures one word.
  • Scope: no stemming, synonyms or embeddings. Give each intent several short phrases in the words your users really type, and avoid intents that share most of their vocabulary.

Combining them

Tickets and Chatwoot both drive the core handoff, so they compose through its events:
Assigning a ticket calls bot.handoff(), and the 'handoff' event opens the Chatwoot conversation with its labels. Closing the ticket calls bot.resume(), and Chatwoot resolves the conversation. Resolving it in Chatwoot resumes the bot, but does not close the ticket: do that from your own bot.on('resume', …) listener if you need it (the event’s reason is 'chatwoot:resolved').

Next steps