npm.io
1.14.1 • Published 2d ago

@clustersoft/mgr-bot-sdk

Licence
MIT
Version
1.14.1
Deps
0
Size
104 kB
Vulns
0
Weekly
0

@clustersoft/mgr-bot-sdk

The official framework for building bots on ClusterSoft Messenger (MGR) — typed, batteries-included, and dependency-free.

npm version types included node license

Build a bot in a handful of lines — commands, buttons, media, sessions — and run it with long-polling or a webhook. The API follows a familiar, conventional bot model, so if you have written a bot before, you already know this one.

  • Zero runtime dependencies — native fetch and http, nothing else
  • TypeScript-first — full types shipped; works from plain JavaScript too
  • Rich messaging — text, media, reactions, typing, edit/delete, inline mode
  • Media, end to end — upload from disk and download received files (CDN-backed)
  • Stateful — Koa-style middleware, per-update state, persistent session (in-memory or Redis)
  • Two transports — long-polling out of the box, webhook for production
  • Resilient — auto-retry with backoff and graceful shutdown

Features at a glance

Area What you get
Messaging send · reply · edit · delete · copy · reactions · typing indicator
Media photos, documents, video, audio, voice — upload bytes or reuse a file_id; download via getFile
Location & contacts send and receive map points and phone contacts
Keyboards inline keyboards + callback queries · inline mode (@bot query)
Routing command · hears (text/regex) · action (buttons) · on (update types)
State middleware (use) · error handler (catch) · ctx.state (per update) · ctx.session (persistent)
Sessions in-memory by default · plug in Redis with your own ioredis client
Transports long-polling · webhook (standalone or mounted) · graceful SIGINT/SIGTERM
Bot profile name, description, short description, command menu, webhook management

Table of contents


Installation

npm install @clustersoft/mgr-bot-sdk

Requires Node.js ≥ 18 (for the built-in fetch).

The package ships compiled CommonJS (dist/*.js) plus type definitions (dist/*.d.ts), so it works identically from TypeScript and plain JavaScript:

// TypeScript / ES modules
import { Bot, Markup } from '@clustersoft/mgr-bot-sdk'
// Plain JavaScript (CommonJS)
const { Bot, Markup } = require('@clustersoft/mgr-bot-sdk')

Quick start

import { Bot, Markup } from '@clustersoft/mgr-bot-sdk'

const bot = new Bot(process.env.BOT_TOKEN!, {
  apiRoot: 'https://api.clustersoft.uz/api/v1/botapi',
})

bot.start((ctx) => ctx.reply('Hello! 👋'))
bot.help((ctx) => ctx.reply('Send me any text and I will echo it.'))
bot.command('ping', (ctx) => ctx.reply('pong'))
bot.hears(/hello/i, (ctx) => ctx.reply('Hi there!'))

bot.on('message', (ctx) => {
  if (ctx.text) ctx.reply(`Echo: ${ctx.text}`)
})

bot.hears(/thanks/i, (ctx) => ctx.react('❤️')) // react to a message

bot.command('report', async (ctx) => {
  await ctx.sendTyping()             // show "typing…" during slow work
  const data = await buildReport()   // takes a few seconds
  await ctx.reply(data)
})

bot.launch() // start long-polling

bot.launch() resolves once polling is running and then stays pending until the process receives SIGINT/SIGTERM (handled automatically) or you call bot.stop().


Getting a token

Open a chat with BotKing (@botking_bot) inside MGR and send /newbot. Follow the prompts to pick a name and username; BotKing returns a token shaped like abc123:XXXXXXXX. Pass it as the first argument to new Bot().

Keep your token secret. Prefer reading it from an environment variable (process.env.BOT_TOKEN) rather than hard-coding it.


Core concepts

  • Update — a single incoming event. Exactly one of message, callback_query, or inline_query is present.
  • Context (ctx) — a per-update wrapper that exposes convenient accessors (ctx.text, ctx.from, ctx.chat, …) and actions (ctx.reply, …).
  • Handler — a function bound to a match rule (a command, a regex, a button). An update is delivered to the first matching handler only, so register more specific handlers before broader ones.
  • Middleware — runs for every update before handlers, in registration order, and can short-circuit or enrich ctx.state.

Handlers

Method Fires when
bot.start(fn) the user sends /start
bot.help(fn) the user sends /help
bot.command(name | names, fn) the message is /name (accepts a single command or an array)
bot.hears(substring | regex, fn) message text matches
bot.action(data | regex, fn) an inline button with matching callback_data is pressed
bot.on(type | types, fn) the update is of the given kind(s): 'message', 'callback_query', 'inline_query'
bot.inlineQuery(fn) shorthand for on('inline_query', fn)
bot.command(['buy', 'purchase'], (ctx) => ctx.reply('Opening the shop…'))
bot.command('echo', (ctx) => ctx.reply(ctx.args || '(nothing to echo)'))

ctx.command gives the parsed command name and ctx.args gives the text after it — for /echo hello world, they are "echo" and "hello world".


Middleware

Middleware follows the Koa signature (ctx, next). Call next() to continue; skip it to stop the update from reaching handlers.

// Logging
bot.use(async (ctx, next) => {
  const t = Date.now()
  await next()
  console.log(`update ${ctx.update.update_id} took ${Date.now() - t}ms`)
})

// Simple auth gate — populate ctx.state for downstream handlers
const ADMINS = new Set(['1001', '1002'])
bot.use(async (ctx, next) => {
  ctx.state.isAdmin = !!ctx.from && ADMINS.has(ctx.from.id)
  await next()
})

bot.command('secret', (ctx) => {
  if (!ctx.state.isAdmin) return ctx.reply('Forbidden')
  return ctx.reply('🔑 Admin area')
})

Error handling

Any error thrown by a handler or middleware is routed to the handler you set with bot.catch(). The default handler logs to console.error.

bot.catch((err, ctx) => {
  console.error(`Error while handling update ${ctx.update.update_id}:`, err)
})

The polling loop itself is resilient: transient getUpdates failures are retried with exponential backoff (1s → 30s, reset on success), so a brief network blip will not crash the bot.


Sessions

Call bot.session() to get a persistent, per-user ctx.session — ideal for multi-step forms and menus. It is loaded before your handlers and saved after, so state carries across messages from the same user.

bot.session()

// Remembers state per user, across messages
bot.command('count', (ctx) => {
  ctx.session.n = (ctx.session.n ?? 0) + 1
  return ctx.reply(`Count: ${ctx.session.n}`)
})

Sessions shine for multi-step conversations — for example, a small form:

bot.command('setname', (ctx) => {
  ctx.session.step = 'name'
  return ctx.reply('What is your name?')
})

bot.on('message', (ctx) => {
  if (ctx.session.step !== 'name' || !ctx.text) return
  ctx.session.name = ctx.text
  ctx.session.step = undefined
  return ctx.reply(`Nice to meet you, ${ctx.session.name}!`)
})

By default the key is "<chatId>:<fromId>" (per user, per chat) and storage is in-memory. For persistent or multi-instance bots, pass a store and/or a custom getSessionKey:

bot.session({
  store: myStore,                         // implements SessionStore
  getSessionKey: (ctx) => ctx.from?.id,   // one session per user, any chat
  defaultSession: () => ({ step: 0 }),
})

A SessionStore is any { get, set, delete } (sync or async). Set ctx.session = null to clear the stored session.

Redis-backed sessions

The SDK ships a RedisSessionStore and stays dependency-free — you bring your own ioredis (or compatible) client. Install it, connect, and plug it in; without it, sessions simply stay in-memory.

import Redis from 'ioredis'
import { Bot, RedisSessionStore } from '@clustersoft/mgr-bot-sdk'

const redis = new Redis(process.env.REDIS_URL) // your own client

bot.session({
  store: new RedisSessionStore(redis, {
    prefix: 'mybot:sess:',  // key prefix (default "session:")
    ttlSeconds: 86400,      // optional expiry, refreshed on write
  }),
})

Any client exposing get/set/del works (the RedisLike interface). Values are JSON-serialised; keys are "<prefix><sessionKey>".


Inline keyboards & callbacks

import { Markup } from '@clustersoft/mgr-bot-sdk'

bot.command('menu', (ctx) =>
  ctx.reply('Choose one:', Markup.inlineKeyboard([
    [Markup.button.callback('Yes', 'yes'), Markup.button.callback('No', 'no')],
    [Markup.button.url('Website', 'https://clustersoft.uz')],
  ])),
)

bot.action('yes', (ctx) => ctx.answerCbQuery('Thanks!', true)) // blocking alert
bot.action('no',  (ctx) => ctx.answerCbQuery('No worries'))    // small toast

You can also match callback data with a regular expression:

bot.action(/^item:(\d+)$/, (ctx) => {
  const id = ctx.callbackData!.split(':')[1]
  return ctx.answerCbQuery(`Selected item ${id}`)
})
Editing messages in place

After a button press, update the message the button was attached to instead of sending a new one — the classic counter / pagination pattern. A bot may edit only its own messages.

const kb = (n: number) => Markup.inlineKeyboard([
  [Markup.button.callback('➖', `set:${n - 1}`), Markup.button.callback('➕', `set:${n + 1}`)],
])

bot.command('counter', (ctx) => ctx.reply('Count: 0', kb(0)))

bot.action(/^set:(-?\d+)$/, async (ctx) => {
  const n = Number(ctx.callbackData!.split(':')[1])
  await ctx.editMessageText(`Count: ${n}`, kb(n))
  await ctx.answerCbQuery()
})
  • ctx.editMessageText(text, extra?) — replace text (and optionally the keyboard).
  • ctx.editMessageReplyMarkup(markup?) — replace only the keyboard; omit it to remove the keyboard. Keeps the existing text.
  • ctx.deleteMessage() — delete the callback message entirely.

These act on the message tied to the current callback. To target an arbitrary message, use the client directly: bot.api.editMessageText(chatId, messageId, text) or bot.api.deleteMessage(chatId, messageId).

// A self-destructing confirmation
bot.action('dismiss', (ctx) => ctx.deleteMessage())

A bot may edit or delete only its own messages — attempting to modify a user's message fails with a 403.


Reply keyboards

A reply keyboard replaces the user's on-screen keyboard with your own custom buttons, shown below the input box. Tapping a button sends its label as an ordinary message, so you handle it like any other text.

import { Markup } from '@clustersoft/mgr-bot-sdk'

bot.command('menu', (ctx) =>
  ctx.reply('Main menu:', Markup.keyboard([
    ['Balance', 'History'],
    ['Settings'],
  ], { resize: true })),
)

bot.hears('Balance', (ctx) => ctx.reply('Your balance is 0'))
  • Markup.keyboard(rows, opts?) — bare strings become buttons; pass { resize: true } to shrink the keyboard and { oneTime: true } to hide it after a single tap.

A button can instead ask the user to share their phone number or location. The reply arrives as a normal message carrying ctx.contact / ctx.location.

bot.command('start', (ctx) =>
  ctx.reply('Please share your phone:', Markup.keyboard([
    [Markup.button.contactRequest('📱 Share phone')],
    [Markup.button.locationRequest('📍 Share location')],
  ], { resize: true, oneTime: true })),
)

bot.on('message', (ctx) => {
  if (ctx.contact) return ctx.reply(`Thanks, ${ctx.contact.phone_number}`)
})

When you're done, remove the keyboard to restore the user's normal one:

bot.command('done', (ctx) => ctx.reply('All set!', Markup.removeKeyboard()))

Internationalisation (i18n)

An MGR update identifies who sent it, but carries no language field — so a bot cannot detect the user's language automatically. The rule that follows is simple: ask once, then remember. The companion package @clustersoft/i18n provides the translation engine plus a /bot layer that plugs straight into this SDK: per-update locale resolution, ctx.t, and a ready-made language picker.

npm install @clustersoft/i18n
Quick start

Register bot.session() first — the chosen locale rides along in the session — then the i18n middleware. From that point every handler has ctx.t.

import { Bot } from '@clustersoft/mgr-bot-sdk'
import { I18n } from '@clustersoft/i18n'
import { BotI18n } from '@clustersoft/i18n/bot'
import '@clustersoft/i18n/bot/augment' // types for ctx.t / ctx.i18n

const i18n = new I18n({ defaultLocale: 'uz', directory: './locales' })
const botI18n = new BotI18n(i18n)

const bot = new Bot(process.env.BOT_TOKEN!, {
  apiRoot: 'https://api.clustersoft.uz/api/v1/botapi',
})

bot.session()                  // locale is stored in the session
bot.use(botI18n.middleware())  // attaches ctx.t and ctx.i18n

bot.start((ctx) => ctx.reply(ctx.t('welcome', { name: 'Eldor' })))
bot.command('balance', (ctx) => ctx.reply(ctx.t('balance', { amount: 45000 })))

bot.launch()
const { Bot } = require('@clustersoft/mgr-bot-sdk')
const { I18n } = require('@clustersoft/i18n')
const { BotI18n } = require('@clustersoft/i18n/bot')

const i18n = new I18n({ defaultLocale: 'uz', directory: './locales' })
const botI18n = new BotI18n(i18n)
const bot = new Bot(process.env.BOT_TOKEN, { apiRoot: '…/api/v1/botapi' })

bot.session()
bot.use(botI18n.middleware())
bot.start((ctx) => ctx.reply(ctx.t('welcome', { name: 'Eldor' })))
bot.launch()
Asking for a language

ctx.i18n.languageKeyboard() builds an inline keyboard with one button per loaded locale — each label written in its own language, the active one marked — emitting locale:<code> when pressed. handleLanguageCallback(bot) registers the matching action() handler: it persists the choice, acknowledges the press, and calls your onChanged.

bot.start(async (ctx) => {
  if (ctx.session.locale) return ctx.reply(ctx.t('welcome', { name: 'Eldor' }))
  await ctx.reply(ctx.t('choose_language'), ctx.i18n.languageKeyboard({ columns: 2 }))
})

bot.command('lang', (ctx) =>
  ctx.reply(ctx.t('choose_language'), ctx.i18n.languageKeyboard()),
)

botI18n.handleLanguageCallback(bot, {
  // ctx.t already speaks the newly chosen language here
  onChanged: (ctx) => ctx.editMessageText(ctx.t('welcome', { name: 'Eldor' })),
})

Register handleLanguageCallback before any broad action(/…/) handler — an update reaches the first matching handler only. Inside a handler you can also switch the locale yourself with await ctx.i18n.setLocale('ru'), which applies it to the rest of the update and persists it.

Where the locale is stored

By default the locale lives on ctx.session (SessionLocaleStore), so it is persisted by whatever session backend the bot already uses — including RedisSessionStore. Nothing else to wire up:

bot.session({ store: new RedisSessionStore(redis) })
bot.use(botI18n.middleware())

To keep the locale outside the session — or to read it from your own user table — pass a store or a resolver:

import Redis from 'ioredis'
import { RedisLocaleStore } from '@clustersoft/i18n'

const botI18n = new BotI18n(i18n, {
  store: new RedisLocaleStore(new Redis(process.env.REDIS_URL!)),
  resolveLocale: (ctx) => db.getUserLang(ctx.from?.id), // highest priority
  getLocaleKey: (ctx) => `${ctx.from?.id}`,             // default "<chatId>:<fromId>"
})

Message files, plural forms, {name} interpolation, namespaces, Intl number/date/currency formatting and the uz (Latin) vs oz (Cyrillic) convention are documented in the @clustersoft/i18n README.


Inline mode

When a user types @your_bot <query> in any composer, your bot receives an inline query and answers with a list of results.

import { articleResult } from '@clustersoft/mgr-bot-sdk'

bot.inlineQuery((ctx) => {
  const q = ctx.inlineQuery!.query || 'hello'
  return ctx.answerInlineQuery([
    articleResult('1', `Send: ${q}`, q, { description: 'Send this text' }),
    articleResult('2', `UPPER: ${q.toUpperCase()}`, q.toUpperCase()),
  ])
})

Webhook mode

For production, let the platform push updates to your public HTTPS endpoint instead of polling.

await bot.launchWebhook({
  domain: 'https://bot.example.com', // public origin the platform POSTs to
  path: '/hook',
  port: 8443,
  secretToken: process.env.WEBHOOK_SECRET, // echoed as X-ClusterSoft-Bot-Secret
})

Or mount the handler on an HTTP server / framework you already run:

import http from 'http'

http.createServer(bot.webhookCallback('/hook')).listen(8443)
await bot.api.setWebhook('https://bot.example.com/hook', {
  secret_token: process.env.WEBHOOK_SECRET,
})

Validate the X-ClusterSoft-Bot-Secret request header against your secretToken at your edge/proxy to reject forged calls.


The Context object

Member Type Description
ctx.update Update the raw incoming update
ctx.message Message? present for message updates
ctx.callbackQuery CallbackQuery? present for button presses
ctx.inlineQuery InlineQuery? present for inline queries
ctx.text string? text of the incoming message
ctx.command string? parsed command name, e.g. echo for /echo hi
ctx.args string text after the command, trimmed
ctx.from User? who triggered the update
ctx.chat Chat? the conversation (for messages / callbacks)
ctx.callbackData string? callback_data of the pressed button
ctx.state Record<string, unknown> per-update scratch space for middleware
ctx.session any persistent per-user session (with bot.session())
ctx.me User the bot's own account
ctx.api ApiClient the full Bot API client
ctx.reply(text, extra?) Promise<Message> reply in the current chat
ctx.replyWithPhoto(file, extra?) / ctx.replyWithDocument(file, extra?) Promise<Message> reply with media (upload or file_id)
ctx.getFileUrl(fileId) Promise<{ file_url }> download URL for received media
ctx.fileId string? file_id of the message's media
ctx.replyWithLocation(lat, lng) / ctx.replyWithContact(phone, name, extra?) Promise<Message> reply with a location / contact
ctx.location / ctx.contact object? shared location / contact on the message
ctx.getChat() Promise<ChatFullInfo> info about the current chat
ctx.sendChatAction(action?) / ctx.sendTyping() Promise<boolean> show a transient status
ctx.editMessageText(text, extra?) Promise<Message> edit the callback message's text/keyboard
ctx.editMessageReplyMarkup(markup?) Promise<Message> edit only the callback message's keyboard
ctx.deleteMessage() Promise<boolean> delete the callback message
ctx.react(emoji?) Promise<boolean> react to the current message (omit to clear)
ctx.answerCbQuery(text?, alert?) Promise<boolean> acknowledge a callback
ctx.answerInlineQuery(results) Promise<boolean> answer an inline query

Bot API client

bot.api (also ctx.api) is a typed HTTP client for the Bot API. Use it for anything the ctx shortcuts don't cover:

await bot.api.setMyCommands([
  { command: 'start', description: 'Start the bot' },
  { command: 'help',  description: 'Show help' },
])

await bot.api.sendMessage(chatId, 'Direct message')

// Call a method that isn't wrapped yet:
await bot.api.raw('someNewMethod', { foo: 1 })

Failed calls throw an ApiError with .code and .description:

import { ApiError } from '@clustersoft/mgr-bot-sdk'

try {
  await bot.api.sendMessage(chatId, '')
} catch (err) {
  if (err instanceof ApiError) console.error(err.code, err.description)
}
Media

A bot can upload a fresh file, re-send media it received by file_id, and download received media.

import { readFileSync } from 'fs'

// Upload from disk (Buffer) — or { source, filename, contentType }
await bot.api.sendPhoto(chatId, readFileSync('cat.jpg'), { caption: 'A cat' })
await bot.api.sendDocument(chatId, {
  source: readFileSync('report.pdf'),
  filename: 'report.pdf',
  contentType: 'application/pdf',
})

// Re-send received media by file_id (a string)
bot.on('message', async (ctx) => {
  if (ctx.fileId) await ctx.replyWithPhoto(ctx.fileId, { caption: 'Got it!' })
})

// Download a received file → short-lived URL
bot.on('message', async (ctx) => {
  if (ctx.fileId) {
    const { file_url } = await ctx.getFileUrl(ctx.fileId)
    console.log('download from', file_url)
  }
})
  • ctx.fileIdfile_id of whatever media the message carries (photo, video, audio, document, sticker, animation, voice).
  • Send methods sendPhoto/sendDocument/sendVideo/sendAudio/sendVoice accept either a file_id string or file bytes (Buffer/Uint8Array or { source, filename?, contentType? }).
  • getFile(fileId, chatId){ file_id, file_url } (URL valid for a few minutes).
Location & contacts
// Send a point on the map, or share a phone contact
await bot.api.sendLocation(chatId, 41.311081, 69.240562)
await bot.api.sendContact(chatId, '+998901234567', 'Aziz', { last_name: 'Karimov' })

// Receive them
bot.on('message', (ctx) => {
  if (ctx.location) return ctx.reply(`📍 ${ctx.location.latitude}, ${ctx.location.longitude}`)
  if (ctx.contact)  return ctx.reply(`📞 ${ctx.contact.first_name}: ${ctx.contact.phone_number}`)
})
Chat info & bot profile
// Info about a chat the bot belongs to
const chat = await bot.api.getChat(chatId)   // { id, type, title?, member_count? }
const here = await ctx.getChat()             // the current chat

// Copy a message (no "forwarded from" header)
await bot.api.copyMessage(targetChatId, fromChatId, messageId)

// Present the bot like BotFather does
await bot.api.setMyDescription('I turn your notes into reminders.')      // empty-chat screen
await bot.api.setMyShortDescription('Notes → reminders, instantly.')     // profile page
await bot.api.deleteMyCommands()                                         // clear the menu
Available methods
Method Purpose
getMe() bot account info
getUpdates(params) fetch updates (used internally by polling)
sendMessage(chatId, text, extra?) send a text message
sendPhoto / sendDocument / sendVideo / sendAudio / sendVoice (chatId, file, extra?) send media (upload bytes or file_id)
sendLocation(chatId, latitude, longitude) send a point on the map
sendContact(chatId, phoneNumber, firstName, extra?) send a phone contact
getFile(fileId, chatId) resolve a received file_id to a download URL
sendChatAction(chatId, action?) show a transient status ("typing…")
copyMessage(chatId, fromChatId, messageId, extra?) send a copy of a message
editMessageText(chatId, messageId, text, extra?) edit a message's text/keyboard
editMessageReplyMarkup(chatId, messageId, markup?) edit only a message's keyboard
deleteMessage(chatId, messageId) delete a message
setMessageReaction(chatId, messageId, emoji?) react to a message (omit emoji to clear)
getChat(chatId) get info about a chat
answerCallbackQuery(id, extra?) acknowledge a button press
answerInlineQuery(id, results) answer an inline query
setMyCommands(commands) / getMyCommands() / deleteMyCommands() manage the command menu
getMyName() the bot's display name
setMyDescription(text) / getMyDescription() empty-chat description (≤512)
setMyShortDescription(text) / getMyShortDescription() profile short description (≤120)
setWebhook(url, extra?) / deleteWebhook(dropPending?) / getWebhookInfo() manage webhooks
raw(method, params?) call any method by name

API reference

new Bot(token, options)
Option Type Default Description
apiRoot string Platform Bot API root, e.g. https://…/api/v1/botapi
bot.launch(options?)
Option Type Default Description
timeout number 30 long-poll timeout, seconds
dropPendingUpdates boolean false skip updates queued while offline
handleSignals boolean true auto-stop on SIGINT/SIGTERM
bot.launchWebhook(options)
Option Type Default Description
domain string public HTTPS origin
path string '/' update endpoint path
port number local port to bind
secretToken string shared secret header value
dropPendingUpdates boolean false skip queued updates on register

Other lifecycle methods: bot.webhookCallback(path?), bot.stop().


Examples

Runnable examples live in examples/:

File Shows
echo.ts polling, middleware, catch, command/args (TypeScript)
echo.js the same idea in plain CommonJS JavaScript
menu.ts inline keyboard + inline mode
counter.ts editing a message in place after a button press
webhook.ts webhook transport
BOT_TOKEN=<token> API_ROOT=https://…/api/v1/botapi npx ts-node examples/echo.ts

License

MIT ClusterSoft

Keywords