@clustersoft/mgr-bot-sdk
The official framework for building bots on ClusterSoft Messenger (MGR) — typed, batteries-included, and dependency-free.
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
fetchandhttp, 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, persistentsession(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
- Features at a glance
- Installation
- Quick start
- Getting a token
- Core concepts
- Handlers
- Middleware
- Error handling
- Sessions
- Inline keyboards & callbacks
- Internationalisation (i18n)
- Inline mode
- Webhook mode
- The
Contextobject - Bot API client
- API reference
- Examples
- License
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, orinline_queryis 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,Intlnumber/date/currency formatting and theuz(Latin) vsoz(Cyrillic) convention are documented in the@clustersoft/i18nREADME.
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-Secretrequest header against yoursecretTokenat 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.fileId—file_idof whatever media the message carries (photo,video,audio,document,sticker,animation,voice).- Send methods
sendPhoto/sendDocument/sendVideo/sendAudio/sendVoiceaccept either afile_idstring or file bytes (Buffer/Uint8Arrayor{ 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