Published and unpublished packages
The On npm channels are installed with
pnpm add and follow semver
(those marked “from the beta” were first published with it). @botmesh/tiktok has "private": true in its
package.json: it is implemented and tested, but it is not published to npm
and its API may still change. Use it from a clone of the monorepo ("workspace:*").
The same goes for the sandbox. The webhook ingress,
@botmesh/server, is on npm.
Choosing a channel
WhatsApp has several options:@botmesh/baileys: links the bot as a WhatsApp Web device (QR or pairing code) and opens its own socket. No public server or webhook, groups and polls work, no per-message cost. It is an unofficial client: automating an account can break WhatsApp’s terms and get the number banned. Keep the rate limiter on.@botmesh/meta: the official WhatsApp Cloud API. Native buttons, lists, templates and WhatsApp Flows forms, no ban risk. Needs a public HTTPS webhook, has no groups, and free-form messages only work inside the 24-hour customer-service window.@botmesh/evolution/@botmesh/wppconnect: you already run an Evolution API or WPPConnect Server. The bot talks to that gateway over REST and receives its webhooks. Both gateways drive WhatsApp Web, so the same caveats as Baileys apply (unofficial, no reliable native buttons). The shared pieces (REST client, media, LID/PN reconciliation) live in@botmesh/wa-gateway-kit, a public dependency of both; you install the channel, not the kit (unless you write a transport for another gateway).@botmesh/twilio/@botmesh/gupshup: you send WhatsApp through that provider’s account. Templates are supported; Twilio also covers SMS/MMS with the same class.@botmesh/gohighlevel: the bot answers HighLevel CRM contacts on the channel they used (SMS, WhatsApp, Email, Facebook, Instagram, Live chat…).
@botmesh/telegram (long polling by default, webhook
optional), @botmesh/discord (gateway socket), @botmesh/messenger and
@botmesh/instagram (Meta webhooks), @botmesh/email (each email thread is a
chat) and @botmesh/tiktok (comments on your own videos and DMs).
Socket, polling or webhook
- Socket / polling (the provider opens the connection): Baileys, Discord, Telegram in polling mode, Email (IMAP IDLE). Nothing to expose to the internet.
- Webhook (
capabilities.pushIngress: true): Cloud API, Messenger, Instagram, Telegram in webhook mode, Evolution, WPPConnect, Twilio, HighLevel, Gupshup, TikTok. Your HTTP server receives the POST and hands the raw body plus the headers toprovider.ingest(), which verifies the signature or token and returns the HTTP status to answer.@botmesh/serverdoes that wiring (raw body, size limits, timeouts, concurrency cap, health endpoints) fornode:http, Express, Fastify and Hono.
@botmesh/server (pnpm add @botmesh/server): one
route per provider, and the GET handshake and the signed POSTs are
handled for you:
ingest() returns:
GET handshake, Telegram has none, and the providers whose
authentication covers the URL take it as well (WPPConnect’s async
ingest(body, headers, url), ingestRequest({ url, body, headers }) on
Twilio and Gupshup).
ingest() does not wait for your handlers, so the platform gets its answer
right away.
The constructor of every webhook provider (Telegram only in webhook mode)
throws when its webhook credential (app secret, auth token or shared
token) is missing, unless you pass allowUnsignedWebhooks: true, which is
meant only for isolated local development. HighLevel is the exception: its
signing public key is bundled.
Capability matrix
Values come from each provider’scapabilities declaration. A feature
marked “no” is degraded, not broken: buttons and lists become a numbered
text menu, ctx.edit sends a new message, ctx.react resolves
{ skipped: true }, ctx.typing is a no-op (see
Messages & media).
Content. What each channel can send natively.
Ingress and presence. How events arrive and which presence hints work.
- Send reactions only describes sending. Several providers still
receive reactions as
reactionevents (Messenger, Instagram, Gupshup, TikTok…). ctx.stream:draft= native draft previews then one final message (Telegram private chats; edits elsewhere),edit= one message edited as the text grows,chunks= no live preview, sentence-sized messages with typing (streaming: 'none'). See Streaming replies.- Forms:
nativesendsflow().form()as a WhatsApp Flow;field by fieldasks each field with text messages. textFormat(not a column): the text markup the channel renders, so Markdown producers such as@botmesh/aican convert it.'whatsapp'(*bold*,_italic_,~strike~): Baileys, Cloud API, Evolution, WPPConnect, Gupshup.'plain'(no markup): Telegram (sent withoutparse_mode), Messenger, Instagram, TikTok.'markdown': Discord. Not declared: Email. Providers that serve several channels decide per chat with the optionaltextFormatFor(chatId), which wins overcapabilities.textFormatand reaches handlers asctx.textFormat: Twilio returns'whatsapp'forwhatsapp:chats and'plain'for SMS; HighLevel follows the contact’s reply channel ('whatsapp'for WhatsApp,'plain'for SMS, Email, FB, IG, Live chat, GMB and custom).- Templates is the
templatecontent kind: Cloud API templates, Twilio Content Templates (ContentSid), HighLeveltemplateId, Gupshup templates. Other providers reject it withUnsupportedContentError. - Every provider except Baileys accepts the
nativecontent kind (ctx.sendNative('<provider id>', payload)) as an escape hatch to the channel’s own API, with a provider-specific payload. Baileys rejects it withUnsupportedContentError. Discord, Messenger, Instagram, Evolution, WPPConnect, Twilio, Gupshup, HighLevel, Email and TikTok also reject a payload addressed to another provider id instead of posting it.
Cross-channel contract
The same rules hold on every channel, so a bot behaves the same wherever it runs. Provider authors check them with the helpers of@botmesh/core/testing
(see Testing).
- Timestamps are epoch milliseconds.
ctx.timestamp,InboundMessage.timestamp,InboundInteraction.timestampandDeliveryStatus.timestampare epoch ms on every channel. Transports that report seconds (WhatsApp, Telegram, the Cloud API) are multiplied by 1000; one that reports no time (Twilio) uses the receive time, never0. Telegram sends no press time for a button, so a callback query carries the receive time. - Long text is split. Each channel declares its text limit and the core splits a
longer
textsend into several messages (see Messages;splitLongText: falseturns it off). - Unsupported operations are absent. An operation the transport cannot perform is
not implemented: the optional method (
typing,markRead,editMessage,deleteMessage) is missing and its capability flag isfalse, soctx.typing(),ctx.markRead()andctx.delete()do nothing. A method that exists performs the operation or rejects; it is never a silent stand-in. - Presence hints are best-effort while disconnected.
typing()andmarkRead()resolve without contacting the transport while the provider’s last connection state is not'ready'(beforestart()connected, while reconnecting, afterstop()): no error, no queue, no later retry. The bot applies the same rule before calling them, logs the skipped hint at debug and counts it inmetrics().presenceSkipped(botmesh_presence_skipped_totalintoPrometheus()). Once connected, a transport failure still rejects.assertPresenceWhileDisconnected()checks a provider. - Too many options reject. A transport that shows fewer buttons, rows or poll
answers than you sent rejects with
UnsupportedContentError(Cloud API, Messenger, Instagram, Discord), unless it has a documented degradation (numbered text on Baileys, Evolution, WPPConnect, Gupshup, TikTok). - A local media path that cannot be read rejects with
UnsupportedContentError(the fs error ascause), never a rawENOENT. - Waits are bounded. Every REST call has a timeout (
requestTimeoutMson the Cloud API, Telegram, Discord, Messenger and Instagram), and aRateLimitedErrornever asks for more than 24 h. - An edit keeps the original id. Where the transport reports text edits (Baileys,
Telegram
edited_message, DiscordMESSAGE_UPDATE), the edit arrives as a message with the new text and the originalref.id; each of those packages exportsisEditedMessage(ctx)and aneditedMessages: 'ignore'option. The core dedupe lets an edit through because its content changed, and drops a redelivery of the same edit. - Poll votes have one shape. Baileys and Telegram emit each vote as a
'poll'event (bot.on('poll', (vote) => …)):vote.refis the poll message,vote.senderIdthe voter andvote.optionsthe current tally of every option with itsvoters. - One naming scheme for low-level helpers. Each provider’s error mapper is
to<Channel>Error(toTelegramError,toDiscordError,toTwilioError…; the Meta channels sharetoGraphErrorfrom@botmesh/graph-core). Telegram, TikTok and the Cloud API keep their oldertoProviderError(and Email itstoEmailProviderError) as deprecated aliases; the channels first published in the beta have only the new names.ingest()returns the HTTP status to answer with (anumber); WPPConnect returns it as aPromise<number>(it resolves unknown LIDs through the gateway before emitting, never waiting for your handlers) and email’singest()takes a raw message, not a webhook, and returns anEmailIngestResult.
Providers
@botmesh/baileys
WhatsApp Web through Baileys.
Links with a QR code (or a pairing code, pairingPhoneNumber) and keeps the session in
sessionsDir (./sessions/<session>): later starts reconnect without a QR. It
depends on the exact @whiskeysockets/baileys 7.0.0-rc14 (there is no stable
Baileys 7 yet); the README explains the pin and the upgrade policy.
- Socket owned by the provider, with reconnection and backoff
(
reconnect: { maxAttempts, baseMs, capMs }tunes it,falseturns it off). - LID and phone-number forms of a user are merged into one stable
senderId. - No native buttons or lists (WhatsApp Web dropped them): use
choose()menus orctx.poll(). - Anti-ban send rate limiting is on by default (
rateLimit). - Reactions and deletes arrive as events, never as messages, so a
catch-all handler or an open
askdoes not take them as text. A text edit arrives as a message with the new text and the originalref.id, like on Telegram:isEditedMessage(ctx)(from@botmesh/baileys) tells it apart, andeditedMessages: 'ignore'drops edits. Only the author of a message this process has seen can edit it: an edit of someone else’s message, of the bot’s own or of an original no longer cached is dropped. ctx.voice()andctx.sticker()accept URLs and streams (downloaded with the same SSRF checks as other media);ctx.poll()needs 2 to 12 unique options.
@botmesh/meta (WhatsApp Cloud API)
The official API. Webhook-based: GET handshake with verifyChallenge(),
POST events with ingest() (see the server example
above).
Setup in Meta: create an app with the WhatsApp product on
developers.facebook.com, generate a permanent system-user token with
whatsapp_business_messaging, copy the sender’s phone number id, then set
the webhook URL and a verify token of your choice under WhatsApp →
Configuration and subscribe it to the messages field (it carries both
messages and delivery statuses).
- Native reply buttons (max 3: more rejects with
UnsupportedContentError, send a list instead), lists, templates and WhatsApp Flows forms. A button withurlgoes out as acta_urllink button (one per message: several links, or a link mixed with reply buttons, reject). Acardkeeps its image (as the media or the interactive header) and its buttons. Graph’s limits are checked before sending: no buttons, repeated button ids or titles (compared after the 20-character cut), a list with no rows, more than 10 rows in total or repeated row ids reject withUnsupportedContentError. Labels are cut on grapheme boundaries, so an emoji is kept whole or dropped, never split. - A missing
accessToken,phoneNumberIdorverifyTokenfails at construction with an error naming the field (phoneNumberIdmay also be a safe integer). Every Graph request has a timeout (requestTimeoutMs, 60 s by default); media uploads have their own (uploadTimeoutMs, 5 min by default). - An interactive reply that is not a button or list press (for example
call_permission_reply) arrives as anunknownmessage with the raw payload inctx.ref.native. - Graph API version:
apiVersiondefaults toDEFAULT_GRAPH_API_VERSIONfrom@botmesh/graph-core(v26.0, released 2026-07-29; Messenger and Instagram use the same default). Meta retires each version about two years after its release, so the default follows the current one and moves in a minor release, after the payloads are checked against Meta’s changelog. PinapiVersion(for example'v24.0') to stay on a version until you migrate. Aplayedstatus (a voice note was listened to) is reported asreadwithplatformData.played: true. - Users with a WhatsApp username may arrive with a BSUID instead of a phone
number; the provider keeps one stable id per person (
identityMap). - Outside the 24 h window free-form sends reject with
OutsideSessionWindowError. The optionalsessionWindowmanager reopens the chat with a template and retries: see 24-hour window and pricing. - No groups, typing or polls in the API, and no delete for everyone: the
provider has no
typing()ordeleteMessage(), soctx.typing()andctx.delete()do nothing (see the cross-channel contract). - Text over 4096 characters is split into several messages by the core.
- While the bot is stopped,
ingest()answers503, so Meta redelivers the webhook after the restart. publishForm()bakes the default submit label of a form into the Flow in the provider’slang(default'en'; set it to your bot’sBotConfig.lang), or inpublishForm(def, { lang })for one Flow. A form’s ownsubmitLabelalways wins.
@botmesh/telegram
Telegram Bot API. Long polling by default; polling: false switches to
webhook mode, where webhookSecret is required.
- Polling needs no public URL: use it in development and for a bot that
runs as one process. Only one poller per token may run (a second one gets
409and the provider stops). - Webhook suits serverless and multi-replica deployments behind HTTPS.
Register the URL once with the Bot API’s
setWebhook, passing the same secret assecret_token.
- Inline keyboards, native polls, reactions, message edits.
ctx.stream()uses native drafts in private chats and edits elsewhere.- In webhook mode
ingest(body, headers)takes the raw bytes, the JSON string or the parsed object, and checks theX-Telegram-Bot-Api-Secret-Tokenheader (any case, plain or FetchHeaders).@botmesh/serverneeds no extra option. Afterstop()it answers503and emits nothing, so Telegram redelivers those Updates once the bot is back. - Group commands addressed to the bot (
/menu@MyBot) reachbot.command('menu'), also in a media caption: the provider drops the suffix when it names this bot (username fromgetMe, or theusernameoption when you passselfId). - Mentions of the bot: an
@MyBotin the text and a command addressed as/menu@MyBotput the bot’sselfIdinctx.mentions, sogroupMode: 'mentions'lets them through. Other@usernamementions carry no user id and are not listed; atext_mention(a user without username) is. - Every Bot API request has a client timeout (
requestTimeoutMs, 120 s by default; long polls getpollTimeoutMs(30 s by default) + 15 s; file uploads getuploadTimeoutMs, 5 min by default), errors never carry the bot token, a 429retry_afteris honoured up to 24 h, andbot.stop()cuts the poll loop’s retry waits at once. - Edited messages reach the bot as messages with the new text and the
original
ref.id(replies, reactions and edits target the edited message).isEditedMessage(ctx)tells them apart; the core deduper lets an edit through because its content changed.editedMessages: 'ignore'turns them off. - Poll votes: polls are sent non-anonymous (
is_anonymous: false), so each vote arrives as apoll_answerupdate and becomes a'poll'event with the tally, like on WhatsApp. Chat members can see who voted;anonymousPolls: truesends anonymous polls, whose votes never reach the bot. Votes are tallied for polls this process sent (in memory, the last 500): a vote on a poll sent before a restart is dropped. Channels do not allow public-voter polls: a poll to a channel is retried once as anonymous (no votes) and that channel gets anonymous polls from then on. - Self-hosted Bot API server: set
baseUrlto it. On plain HTTP or a private network, media downloads also needmediaDownload: { allowHttp: true, allowPrivateHosts: true }(plus a largermaxBytes). Run the server without--local, whose file paths are not served over HTTP.
@botmesh/messenger
Facebook Messenger through the Graph Send API, with the same webhook
verification as the Cloud API.
- Buttons and lists are native; reactions arrive but cannot be sent
(subscribe the webhook to
message_reactions;emojiis the character andremovedis set onunreact). - Text is capped at 2000 characters (
maxTextLength): longer text is split into several messages, andctx.stream()chunks fit the Send API. - More than 3 buttons (button template) or 10 list rows (generic template)
reject with
UnsupportedContentErrorinstead of dropping options. - Each Send API call times out after
requestTimeoutMs(60 s by default). - No text is cut: a list sends its text before the generic template (which has no header), and button text past the template’s 640 characters goes first as plain text.
- Several attachments in one message arrive as one message each (
<mid>,<mid>#2…). An edited message (subscribemessage_edits) arrives again with its original id;isEditedMessage(ctx)tells it apart. - Message tags:
bot.send(psid, text, { platformData: { tag: 'HUMAN_AGENT' } })sends after the 24-hour window (messaging_type,notification_typeandpersona_idare accepted too). - While the bot is stopped,
ingest()answers503, so Meta redelivers the webhook after the restart.
@botmesh/instagram
Instagram DMs of a professional account connected to a Facebook Page
(Messenger API for Instagram).
- Buttons go out as quick replies (at most 13, titles cut to 20 characters,
unique ids and titles, attached to a non-empty text; anything else rejects
with
UnsupportedContentErrorinstead of dropping options); lists degrade to numbered text. Every Send API request times out afterrequestTimeoutMs(60 s). - DMs only (no groups); reactions arrive but cannot be sent.
- Text is capped at 1000 UTF-8 bytes (
maxTextBytes, plusmaxTextLength1000), so non-ASCII text fits less: longer text is split into several messages by bytes, and thectx.stream()chunks fit too. - Attachments carry no caption: a media caption goes out as a text message right after the attachment, and a card image after the card text (an image-only card sends just the image; one with neither is rejected).
- A DM with several photos arrives as one message per photo (ids
<mid>,<mid>#2,<mid>#3…). - While the bot is stopped,
ingest()answers503, so Meta redelivers the webhook after the restart.
@botmesh/discord
Discord gateway (WebSocket) for events plus REST for sending.
- Buttons, select menus, polls, reactions and edits are native;
ctx.stream()edits one message as the text grows. - The bot keeps one reaction per message, like WhatsApp/Telegram: a new
ctx.react(emoji)replaces its previous one andctx.react('')removes it. Discord deletes reactions by emoji, so the provider remembers the emoji it set (in memory,reactionMemory, default 1000 messages); an unknown one (set before a restart, or evicted) rejects withUnsupportedContentError. - Interactions have a hard acknowledgement deadline
(
interactionsRequireAck), so the core acknowledges each one exactly once for you: a deferred ack before the handler’s first send, shortly before the ~3 s deadline, or when the handler ends, whichever comes first. Callctx.acknowledge('text')before replying to answer with an immediate text instead; once the automatic ack went out,ctx.acknowledge('text')sendstextas a normal reply andctx.acknowledge()does nothing. ctx.interaction.idis the bare interaction id: its token stays inside the provider. As a backstop, the provider also defers any interaction still unanswered afterautoAcknowledgeMs(default 2000 ms;falseturns it off). A latectx.acknowledge('text')after the ack goes out as a followup, withappIdor else the application id Discord sends on READY and on each interaction; if none is known the call rejects withProviderErrorinstead of dropping the text.- More than 25 buttons (5 rows of 5), 25 select-menu options or 10 poll
answers reject with
UnsupportedContentErrorinstead of dropping options; text over 2000 characters is split into several messages. - Each REST call times out after
requestTimeoutMs(60 s by default); file uploads afteruploadTimeoutMs(5 min). - System messages (member joins, pins, boosts…) and messages from bots are
not routed. A message with no text and no file arrives as
sticker(with the sticker image as media) orunknown(embeds only, or the Message Content intent is off), never as an empty text. - Every message the provider sends carries
allowed_mentions(default{ parse: ['users'] }, optionallowedMentions), so echoed text cannot ping@everyone,@hereor a role;SendOptions.mentions,platformData.allowed_mentionsandnativepayloads override it. A quoted reply still pings the replied author (replied_user: true; setallowedMentions.replied_user: falseto reply silently). - Ids go into REST URLs percent-encoded, and an empty,
.or..id rejects withProviderError: a crafted id never reaches another route. Native poll votes are not delivered as events. - A forwarded message carries the forwarded text and media (from its
snapshot), not a quoted reply; one with nothing readable arrives as
unknown. A native poll arrives astype: 'poll'with its question as body. - Edited messages (
MESSAGE_UPDATEwithedited_timestamp) arrive as messages with the new text and the originalref.id;isEditedMessage(ctx)from@botmesh/discordtells them apart andeditedMessages: 'ignore'drops them. Other updates (embed unfurls, pins, flags) are not routed: the provider delivers an edit once peredited_timestamp(it remembers the last one for the 1000 most recently edited messages), so pinning an edited message does not re-deliver it.
@botmesh/evolution
Evolution API v2, a
self-hosted WhatsApp REST gateway built on Baileys. Outbound: REST with the
apikey header. Inbound: the instance webhook, authenticated with a shared
secret header or Evolution’s jwt_key (HS256 JWT).
start()checks the instance; when it is not paired, the QR or pairing code arrives in theconnectionupdate (authPayload).- LID/PN twins sent by Evolution are reconciled before emitting; pass
identityPersistenceto keep ids stable across restarts. - Polls, reactions, edits (text), typing and read receipts; buttons, lists
and cards degrade to numbered text;
templateis rejected, and so is a poll WhatsApp cannot carry (fewer than 2 or more than 12 options, empty or repeated ones). - If Evolution stops offering QR codes before the number is paired
(
QRCODE_LIMIT), theconnectionupdate isclosedwith reasonsessionInvalid: callstart()again for a new QR. - Keep the instance webhook’s
webhookByEventsoff: with it, Evolution adds the event name to the URL and an exact-path route answers 404.
@botmesh/wppconnect
WPPConnect Server,
a self-hosted gateway on WhatsApp Web (WA-JS). REST with a Bearer token;
webhooks authenticated with a token in the webhook URL.
ingest()is async and takes the request URL as third argument, because wppconnect-server cannot add headers: the token travels in the query string.- Unknown LIDs are resolved to phone numbers (
contact/pn-lid) before emitting. markReadmarks the whole chat as seen; buttons and lists degrade to numbered text.- The connection follows the server’s
status-findwebhooks:isLogged/qrReadSuccess→ready,notLoggedandqrcode→authenticating, unlinking the device from the phone (disconnectedMobile) →closedwithsessionInvalid.bot.stop()does not close the session on the server.
@botmesh/twilio
Twilio Programmable Messaging: WhatsApp and SMS/MMS with one class.
Webhooks verified with X-Twilio-Signature, which covers the exact public
URL.
chatIdis Twilio’sFromas is (whatsapp:+57…or+57…): the same person on WhatsApp and SMS is two chats.- Text up to 1600 characters; media only from public URLs; buttons and
lists become numbered text;
templatemaps toContentSid+ContentVariables. ingestRequest({ url, body, headers })for per-request URLs (multi-tenant). WithwebhookPath(URL rebuilt from theHost/X-Forwarded-*headers), pass the request’s path and query as the third argument ofingest()(@botmesh/serverdoes): a query string Twilio called with is then part of the signed URL.- Error 63016 maps to
OutsideSessionWindowError. - A message with several attachments (
NumMedia> 1, up to 10) arrives as one message per attachment, in order: the first keeps theMessageSidand the text (Body); attachmentigets the idMessageSid:iand an empty body (unique ids, stable across webhook retries).
@botmesh/gohighlevel
HighLevel (GoHighLevel / LeadConnector) Conversations API. Inbound through
the InboundMessage webhook (Ed25519 X-GHL-Signature), outbound with
POST /conversations/messages on the channel the contact last used.
chatId= HighLevelcontactId: one chat per contact across channels; the channel of each message is inctx.chat.platformData.channel.- HighLevel’s public key is bundled (
GHL_WEBHOOK_PUBLIC_KEY), so no webhook secret is needed.ingest()needs the raw body. - Media only from public URLs;
templatemaps totemplateId. - An Email’s HTML body arrives as plain text in
ctx.body;ctx.raw.bodykeeps the HTML andctx.chat.platformData.subjectthe subject.
@botmesh/gupshup
Gupshup WhatsApp API: v1 send endpoints, v2 callbacks. Gupshup does not sign
callbacks, so they are authenticated with a shared token in the callback URL
(or a header).
- Buttons go out as quick replies (up to 3), then as a list (up to 10),
then as numbered text; lists are native up to 10 rows. The list button of
degraded buttons reads
listButtonText(default'Options', max 20 characters;'Opciones'for a Spanish bot). Longer labels are cut to WhatsApp’s limits (titles 20/24 characters) instead of being rejected. - With
@botmesh/server, mount the provider as a route: the ingress passes the request URL toingest(), so the?token=callback works as is. - Use a URL-safe
webhookToken(openssl rand -hex 32). A base64 secret pasted raw into?token=also matches (a literal+is not read as a space), and so does its percent-encoded form. chatId= the sender’s phone digits (no+), exactly what the send API expects.- Media only from public URLs. Gupshup also documents IP allow-listing: do both in production.
@botmesh/email
Email: IMAP IDLE to receive and SMTP to reply. Each email thread is
a chat, so ask() flows continue when the customer replies to the email.
imapflow and nodemailer are optional peer dependencies loaded lazily.
threading: 'thread'(default, one chat per thread) or'sender'(one chat per address); persist threads withthreadStore: cursorThreadStore(store). That store also keeps the mailbox UID cursor, so after a restart the bot resumes after the last message it processed and never redelivers mail (even withmarkSeen: false).- Loop protection against auto-replies and mailing lists, bounce handling
(
status: 'failed'events, suppression of hard-bounced addresses). Fromis not authenticated by email itself: userequireAuthentication: { authservId }to accept only mail your MTA authenticated. Hostile headers are contained: addresses with control characters are dropped, oversized Message-IDs ignored andReferenceschains capped.securemeans implicit TLS (default except IMAP port 143, and only SMTP port 465); against a plain-text or STARTTLS portstart()/send()reject with aTLS handshake failedmessage that says which option to set.- Without IMAP,
ingest(rawMime)accepts raw MIME from an inbound-mail webhook and resolves{ status, accepted, reason? }. - A transport built from
smtpis closed onstop()and rebuilt on the next send (pooled ones too); an injectedsmtpTransportis yours andstop()never closes it.
@botmesh/tiktok (not on npm yet)
TikTok API for Business (v1.3): comments on the account’s own videos and
direct messages (Business Messaging API, approval required). Webhooks
verified with TikTok-Signature.
- Chats:
dm:<conversation_id>(DM),comment:<video_id>:<comment_id>(comment thread, a group chat) andhic:<comment_id>(high-intent comment).ctx.chat.platformData.surfacetells them apart. - DMs: text (up to 6000 characters), images, Q&A cards for buttons (1 to 3); comments: text replies up to 150 characters. Buttons or lists a Q&A card cannot hold (more than 3 options, URL buttons, titles over 40 characters) and menus sent to a comment thread degrade to a numbered text menu, as on Baileys.
- The bot cannot start a conversation, and TikTok limits messages per 48 h
window (
OutsideSessionWindowError).
Upgrading from a 2.0 alpha
New bots can skip this section. If you ran a bot on an early 2.0 alpha, the cross-channel contract changed some behaviour; the per-package details are in Upgrade notes: channels & plugins and Migrating to the stable API.What changed on every channel
What changed on every channel
ctx.timestampis epoch milliseconds everywhere. Baileys, the Cloud API andMockProviderused to report epoch seconds. Drop any* 1000(new Date(ctx.timestamp)is now right). The same goes forInboundMessage,InboundInteractionandDeliveryStatustimestamps in custom hooks. A third-party provider that still emits seconds keeps working: the core converts a value below1e12forctx.timestampand the message log, but update it to emit ms (assertInboundContractflags it).- Telegram button presses carry the receive time, not the date of the message that holds the keyboard.
- No-op methods were removed:
CloudAPIProvider#typing()and#deleteMessage(),TelegramProvider#markRead()andDiscordProvider#markRead(). They did nothing; usectx.typing()/ctx.delete()/ctx.markRead()(no-ops there) orprovider.typing?.(…). - Long text is split into several messages instead of being rejected with
PayloadTooLargeError.new Bot({ splitLongText: false })restores the old behaviour.before('send')hooks still run once per message (on the whole text, before the split);after('send')hooks run once per part. - Too many options reject on Discord (over 25 buttons or menu options, over 10 poll
answers), Messenger (over 3 buttons, over 10 list rows) and Instagram (over 13 quick
replies) with
UnsupportedContentError; they used to drop the extra options silently. - Discord defers unanswered interactions after 2 s.
autoAcknowledgeMs: falserestores the old behaviour. A latectx.acknowledge('text')then goes out as a followup; with noappIdand no application id received from Discord it rejects. MockProviderreportscapabilities.typingandreadReceiptsasfalseunless atyping()/markRead()method exists (they had no effect before either), and emits epoch-ms timestamps.RateLimitedError.retryAfterMsis capped at 24 h.- Telegram polls are sent non-anonymous so their votes reach the bot as
'poll'events.new TelegramProvider({ anonymousPolls: true })keeps anonymous polls. - Discord edits are delivered as messages with the original
ref.id; a handler that should not see them checksisEditedMessage(ctx)or setseditedMessages: 'ignore'. - Renamed helpers (the old names are deprecated aliases until v3): Telegram
pollTimeout(seconds) →pollTimeoutMs;toProviderError→toTelegramError,toTikTokError;toEmailProviderError→toEmailError;@botmesh/meta’stoProviderError→toGraphErrorfrom@botmesh/graph-core. Discord, Messenger and Instagram dropped their alphatoProviderErrorbefore their first npm release: usetoDiscordError,toMessengerErrorandtoInstagramError.
Next steps
- Messages & media: what each helper does on each transport.
- Platform sandbox: run a bot on the real providers against local simulators, without accounts.
- Webhook server: mount webhook providers with
@botmesh/server. - 24-hour window and pricing: Cloud API session window and usage tracking.
- Forms: native WhatsApp Flows on the Cloud API, field by field elsewhere.