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>.
How they fit in the pipeline
Three of them hookbefore('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: nodetectIntentcall is made while a human is in charge (see Dialogflow).- Dialogflow routes are
bot.whenpredicates, so they win over theaiPlugincatch-all whatever the plug order. The rule “installaiPluginafterslashCommandsPlugin” 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.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,webhookSecretorinsecureNoWebhookAuth: true. Keep the last one for an endpoint that is not reachable from outside. - The token is accepted in
?token=,x-chatwoot-tokenorAuthorization: Bearer. Chatwoot itself (checked on 4.7) does not sign account webhooks, so put the token in the webhook URL;webhookSecretis for a sender or proxy that signs as described next. The HMAC-SHA256 signature goes inx-chatwoot-signature(hex,sha256=prefix allowed) and is computed over${timestamp}.${rawBody}, withx-chatwoot-timestamp(seconds) withinwebhookToleranceSec(300). A signature over the bare body never expires, so a captured request could be replayed: it is rejected unless you setwebhookAllowUntimedSignature: true. Comparisons are constant-time. handleWebhookanswers401without valid auth,400on invalid JSON and503before the plugin is installed; it never throws.webhookHandleronly acceptsPOSTand caps the body at 1 MiB.- Outgoing HTTP goes through
secureFetchfrom@botmesh/http: HTTPS only, SSRF guard, timeouts and no redirects, soapi_access_tokennever reaches another origin. A self-hosted Chatwoot on a LAN needshttp.allowPrivateHosts(andhttp.allowHttpfor plain HTTP): anhttp://baseUrlwithouthttp.allowHttpthrows 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. UsesyncMode: '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 asctx.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:
assigneeis a free string. Build the panel onbot.injectTools.ticketsand protect it yourself. -
With
handoffOnAssign, only the first assignment hands the chat off: a reassignment keeps the handoff and itsmeta.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. Callbot.resume(chatId)yourself if the bot should answer again. -
metamust be a plain JSON object (noDate,Map, class instances, functions orBigInt); anything else is rejected before the ticket is stored. A property set toundefinedis dropped, as in JSON, so{ email: user?.email }or{ ...prev, key: undefined }(to remove a key) work;undefinedinside 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 includeclosedscans 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 asdurableKvTicketStorage(kv)over the sameDurableKv(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, thenTicketContentionError). WithreuseOpen(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 thatget,listandqueuecannot see.
Moderation
Group moderation that runs before any handler.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 fromn) 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
spamare 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/…).allowInvitesmatches the exact code. actiontakes one action or several in order.autoKickOnLink: trueis shorthand forlinkFilter: { action: ['delete', 'kick'] }and only applies when nolinkFilteris 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.
Moderation limitations
- State (spam windows, warnings, mutes) lives in memory and is lost on restart.
- No provider in the monorepo exposes
groups.removeParticipants/getAdminsyet, sokickdegrades tomuteand admin detection relies onadmins/isAdmin. GroupAdminApiandModerationProvider.groupsare@experimental(outside semver) until@botmesh/coredefines a groups capability for providers; their shape may change in a minor release.
Dialogflow
Runs every inbound text through Dialogflow ES or CXdetectIntent 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: { 'book.table': bookingFlow, greeting: (ctx) => ctx.reply('Hi!') }.
For CX, add cx: { agentId, location, environment? }.
How it works
- The
before('message')hook callsdetectIntentand stores the result for that message.ctx.tools.dialogflow.resultFor(ctx)reads it from any handler. whenDialogflowIntentregisters abot.whenpredicate that reads that result (no second call). If the intent matches with at leastminConfidence, the target runs; otherwise the message falls through to other matchers and the catch-all.- Not sent to Dialogflow: empty messages, interactions, commands
(
commandPrefix, default'/'), texts longer thanmaxTextLength(256 characters, the API limit), messages from a chat in handoff, messages from a user inside an active conversation (skipActiveConversation, defaulttrue) and anythingfilterrejects. - If
detectIntent(orfilter) fails, it is logged,onError(error, ctx)is called and the message keeps flowing. A401drops the cached access token and retries once with a fresh one.
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':minConfidenceoutside 0–1, an unknownfulfillmentorsessionScope, an emptycommandPrefix, a malformedlocation…) throwDialogflowErrorsynchronously atbot.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__,constructorandprototype: 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 aTypeErrorwhen it is registered.
Dialogflow limitations
- Only text is sent (no audio, no events). CX form filling lives in pages:
allRequiredParamsPresentis alwaystruethere.
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.
nluPluginis a plugin spec: plug it asbot.plug(nluPlugin, config). It puts anIntentEngineinctx.tools.nlu(andbot.injectTools.nlu).whenIntent(bot, engine, intent, handler)is abot.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? }ornull;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!equalshola), 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 indefine(): it could never match. Punctuation splits words, so an id likeA-1does not fill one slot. An exact phrase, or a phrase with{slot}placeholders that lines up with the whole message, scores1; 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: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').