Channels (providers)
The bot engine (@botmesh/core) does not know about any transport. A provider connects it to one channel: it receives the channel's events, turns them into neutral InboundEvents and sends OutboundContent back. The same flows, triggers and plugins run on every channel; only the constructor changes.
import { Bot } from '@botmesh/core'
const bot = new Bot({ provider }) // any provider belowThis page lists every provider in the repository, how each one receives events, and what it can and cannot do.
Status: public vs experimental
| Package | Directory | Status | Transport |
|---|---|---|---|
@botmesh/baileys | baileys-provider | Public | WhatsApp Web (Baileys socket) |
@botmesh/meta | meta-provider | Public | WhatsApp Cloud API (official) |
@botmesh/telegram | telegram-provider | Public | Telegram Bot API |
@botmesh/messenger | messenger-provider | Experimental | Facebook Messenger (Send API) |
@botmesh/instagram | instagram-provider | Experimental | Instagram DMs (Messenger API for Instagram) |
@botmesh/discord | discord-provider | Experimental | Discord (gateway + REST) |
@botmesh/evolution | evolution-provider | Experimental | Evolution API v2 (self-hosted WhatsApp gateway) |
@botmesh/wppconnect | wppconnect-provider | Experimental | WPPConnect Server (self-hosted WhatsApp gateway) |
@botmesh/twilio | twilio-provider | Experimental | Twilio Programmable Messaging (WhatsApp + SMS/MMS) |
@botmesh/gohighlevel | gohighlevel-provider | Experimental | HighLevel (LeadConnector) Conversations API |
@botmesh/gupshup | gupshup-provider | Experimental | Gupshup WhatsApp API |
@botmesh/email | email-provider | Experimental | Email (IMAP IDLE + SMTP) |
@botmesh/tiktok | tiktok-provider | Experimental | TikTok API for Business (comments + Business Messaging) |
Public packages are installed with pnpm add. Experimental packages have "private": true in their package.json: they are implemented and tested, but they are not published to npm and their API may change. Use them from this monorepo ("workspace:*").
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 the private@botmesh/wa-gateway-kit; you install the channel, never the kit.@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...).
Other channels: @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, health endpoints) fornode:http, Express, Fastify and Hono.
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's capabilities 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).
| Provider | Ingress | Buttons | Lists | Polls | Send reactions | Edit | Typing | Read receipts | Groups | Templates | ctx.stream | Forms |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Baileys | socket | no | no | yes | yes | no | yes | yes | yes | no | chunks | field by field |
Cloud API (meta) | webhook | yes | yes | no | yes | no | no | yes | no | yes | chunks | native (WhatsApp Flows) |
| Telegram | polling or webhook | yes | no (inline keyboard) | yes | yes | yes | yes | no | yes | no | draft | field by field |
| Messenger | webhook | yes | yes | no | no | no | yes | yes | no | no | chunks | field by field |
| webhook | yes (quick replies) | no | no | no | no | yes | yes | no | no | chunks | field by field | |
| Discord | socket | yes | yes | yes | yes | yes | yes | no | yes | no | edit | field by field |
| Evolution | webhook | no | no | yes | yes | yes | yes | yes | yes | no | chunks | field by field |
| WPPConnect | webhook | no | no | yes | yes | yes | yes | yes | yes | no | chunks | field by field |
| Twilio | webhook | no | no | no | no | no | yes (WhatsApp only) | no | no | yes | chunks | field by field |
| HighLevel | webhook | no | no | no | no | no | no | yes | no | yes | chunks | field by field |
| Gupshup | webhook | yes | yes | no | no | no | no | no | no | yes | chunks | field by field |
IMAP IDLE (or ingest()) | no | no | no | no | no | no | no | no | no | chunks | field by field | |
| TikTok | webhook | yes (1 to 3, DMs; more → numbered text) | yes (up to 3 rows, DMs; more → numbered text) | no | no | no | yes (DMs) | yes (DMs) | no | no | chunks | field by field |
- 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 and HighLevel 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); the oldertoProviderErrornames remain as deprecated aliases.ingest()returns the HTTP status to answer with (anumber); WPPConnect returns it as aPromise<number>(it checks the token asynchronously) and email'singest()takes a raw message, not a webhook, and returns anEmailIngestResult.
| Provider | maxTextLength | maxTextBytes |
|---|---|---|
| Baileys, Evolution, WPPConnect | 65 536 | |
Cloud API (meta), Gupshup | 4096 | |
| Telegram | 4096 | |
| Discord, Messenger | 2000 | |
| 1000 | 1000 | |
| Twilio | 1600 | |
| TikTok | 6000 (DMs; comment replies allow 150) | |
| HighLevel, Email | not declared (no split) |
Providers
@botmesh/baileys
WhatsApp Web through Baileys. Links with a QR code (or a pairing code, pairingPhoneNumber) and keeps the session in sessionsDir.
import { BaileysProvider } from '@botmesh/baileys'
const provider = new BaileysProvider({ session: 'my-bot' })- 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().
import { CloudAPIProvider } from '@botmesh/meta'
const provider = new CloudAPIProvider({
accessToken: process.env.META_TOKEN!, // system-user token
phoneNumberId: process.env.META_PHONE_ID!,
verifyToken: process.env.META_VERIFY_TOKEN!, // the one you set in Meta
appSecret: process.env.META_APP_SECRET!, // verifies X-Hub-Signature-256
})- 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.
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.
import { TelegramProvider } from '@botmesh/telegram'
const provider = new TelegramProvider({ token: process.env.TELEGRAM_TOKEN! })
// Webhook mode: the same secret goes to setWebhook (secret_token)
const hooked = new TelegramProvider({
token: process.env.TELEGRAM_TOKEN!,
polling: false,
webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET!,
})- 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). - Every Bot API request has a client timeout (
requestTimeoutMs, 120 s by default; long polls getpollTimeoutMs(30 s by default; the deprecatedpollTimeouttakes seconds) + 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.
import { TelegramProvider, isEditedMessage } from '@botmesh/telegram'
const selfHosted = new TelegramProvider({
token: process.env.TELEGRAM_TOKEN!,
baseUrl: 'http://telegram-bot-api:8081',
mediaDownload: { allowHttp: true, allowPrivateHosts: true, maxBytes: 200 * 1024 * 1024 },
})
bot.before('message', async (ctx, next) => { if (!isEditedMessage(ctx)) await next() }) // skip edits@botmesh/messenger (experimental)
Facebook Messenger through the Graph Send API, with the same webhook verification as the Cloud API.
import { MessengerProvider } from '@botmesh/messenger'
const provider = new MessengerProvider({
accessToken: process.env.FB_PAGE_TOKEN!, // Page access token (pages_messaging)
verifyToken: process.env.META_VERIFY_TOKEN!,
appSecret: process.env.META_APP_SECRET!,
})- 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).
@botmesh/instagram (experimental)
Instagram DMs of a professional account connected to a Facebook Page (Messenger API for Instagram).
import { InstagramProvider } from '@botmesh/instagram'
const provider = new InstagramProvider({
accessToken: process.env.FB_PAGE_TOKEN!, // token of the connected Page
verifyToken: process.env.META_VERIFY_TOKEN!,
appSecret: process.env.META_APP_SECRET!,
})- Buttons go out as quick replies (at most 13; more reject with
UnsupportedContentError); 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. - More than 13 quick replies reject with
UnsupportedContentErrorinstead of dropping options. - Each Send API call times out after
requestTimeoutMs(60 s by default).
@botmesh/discord (experimental)
Discord gateway (WebSocket) for events plus REST for sending.
import { DiscordProvider, GatewayIntents } from '@botmesh/discord'
const provider = new DiscordProvider({ token: process.env.DISCORD_TOKEN! })
// Default intents: Guilds | GuildMessages | MessageContent.
// Add GatewayIntents.DirectMessages to the `intents` bitmask to receive DMs.- 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. (Up to2.0.0-alpha.2the core did not ack on its own; handlers that already callctx.acknowledge()first keep working unchanged.) ctx.interaction.idis the bare interaction id: its token stays inside the provider (the alpha"<id>:<token>"form is still accepted). As a backstop, the provider also defers any interaction still unanswered afterautoAcknowledgeMs(default 2000 ms;autoAckMsis a deprecated alias;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 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 (experimental)
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).
import { EvolutionProvider } from '@botmesh/evolution'
const provider = new EvolutionProvider({
baseUrl: 'http://localhost:8080',
apiKey: process.env.EVOLUTION_API_KEY!,
instance: 'my-instance',
webhookSecret: process.env.EVOLUTION_WEBHOOK_SECRET!, // or webhookJwtKey
})
// POST /webhook/evolution → res.sendStatus(provider.ingest(body, headers))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.
@botmesh/wppconnect (experimental)
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.
import { WPPConnectProvider } from '@botmesh/wppconnect'
const provider = new WPPConnectProvider({
baseUrl: 'http://localhost:21465',
session: 'my-bot',
secretKey: process.env.WPP_SECRET_KEY!, // or token: '<generated bearer>'
webhookToken: process.env.WPP_WEBHOOK_TOKEN!, // webhook URL: …/webhooks/wpp?token=<it>
})
// POST /webhooks/wpp → res.sendStatus(await provider.ingest(body, headers, req.originalUrl))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.
@botmesh/twilio (experimental)
Twilio Programmable Messaging: WhatsApp and SMS/MMS with one class. Webhooks verified with X-Twilio-Signature, which covers the exact public URL.
import { TwilioProvider } from '@botmesh/twilio'
const provider = new TwilioProvider({
accountSid: process.env.TWILIO_SID!,
authToken: process.env.TWILIO_TOKEN!, // signs webhooks + REST auth
whatsappFrom: 'whatsapp:+14155238886',
smsFrom: '+15017122661',
webhookUrl: 'https://bot.example.com/twilio/webhook', // EXACT URL set in Twilio
})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).- 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 (experimental)
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.
import { GoHighLevelProvider } from '@botmesh/gohighlevel'
const provider = new GoHighLevelProvider({
accessToken: process.env.GHL_TOKEN!, // Private Integration Token
locationId: process.env.GHL_LOCATION_ID!,
// defaultChannel: 'SMS', // until the contact writes
})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.
@botmesh/gupshup (experimental)
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).
import { GupshupProvider } from '@botmesh/gupshup'
const provider = new GupshupProvider({
apiKey: process.env.GUPSHUP_API_KEY!,
appName: 'MyBotApp',
source: '917834811114', // the app's number
webhookToken: process.env.GUPSHUP_WEBHOOK_TOKEN!, // callback: …/gupshup?token=<it>
})
// POST /gupshup → res.sendStatus(provider.ingestRequest({ url: req.originalUrl, body, headers }))- 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'Opciones', max 20 characters). 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 (experimental)
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.
import { EmailProvider } from '@botmesh/email'
const provider = new EmailProvider({
address: 'support@acme.com',
imap: { host: 'imap.example.com', user: 'support@acme.com', pass: process.env.IMAP_PASS! },
smtp: { host: 'smtp.example.com', user: 'support@acme.com', pass: process.env.SMTP_PASS! },
})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.- 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 (experimental)
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.
import { TikTokProvider } from '@botmesh/tiktok'
const provider = new TikTokProvider({
accessToken: process.env.TIKTOK_TOKEN!, // or an async refresh function
businessId: process.env.TIKTOK_BUSINESS_ID!, // open_id of the account
appSecret: process.env.TIKTOK_APP_SECRET!, // verifies TikTok-Signature
selfUniqueIdentifier: process.env.TIKTOK_SELF_UID!, // required to receive comments
})- 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).
Migrating from the 2.0 alphas
The cross-channel contract changed some behaviour of the alpha releases. Check these when you upgrade:
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,toDiscordError,toMessengerError,toInstagramError,toTikTokError;toEmailProviderError→toEmailError;@botmesh/meta'stoProviderError→toGraphErrorfrom@botmesh/graph-core.
Next steps
- Webhook server: mount webhook providers with
@botmesh/server. - 24-hour window and pricing: Cloud API session window and usage tracking.
- Messages & media: what each helper does on each transport.