This page is machine-translated from the Russian original. If something reads oddly, the Russian version is the source of truth — open an issue.
Short complete examples for frequent tasks: an echo skill, registration, a game with a score, a product card, pagination, an HTTP request with a timeout, Alice authorization, buttons with a payload, a logger, NLU and i18n plugins, Telegram inline mode.
In all recipes bot is a Bot instance created and configured as in the
minimal example; imports are shown where a recipe
uses something beyond Bot and BotController. Concepts are explained in the guide,
full signatures are in the API reference.
import { Bot, WELCOME_INTENT_NAME, FALLBACK_COMMAND } from 'umbot';
import { fullPlatforms } from 'umbot/plugins';
const bot = new Bot()
.use(fullPlatforms)
.setAppConfig({ isLocalStorage: true })
.setAppMode('strict_prod');
bot.addCommand(WELCOME_INTENT_NAME, ['hello'], (_, bc) => {
bc.text = 'Hi! I repeat after you.';
bc.buttons.addBtn('Help');
});
bot.addCommand(FALLBACK_COMMAND, [], (userCommand, bc) => {
bc.text = `You said: ${userCommand}`;
});
bot.start('0.0.0.0', 3000);
import { Bot, BotController, IUserData } from 'umbot';
interface RegData extends IUserData {
name?: string;
age?: number;
}
// The trigger command — userData is not used here, no need to type it
bot.addCommand('register', ['register'], (_, bc) => {
bc.text = 'Enter your name:';
bc.thisIntentName = 'reg_name';
});
// A step — typed through the generic parameter
bot.addStep('reg_name', (bc: BotController<RegData>) => {
bc.userData.name = bc.originalUserCommand ?? '';
bc.text = `Hi, ${bc.userData.name}! How old are you?`;
bc.thisIntentName = 'reg_age';
});
bot.addStep('reg_age', (bc: BotController<RegData>) => {
const age = parseInt(bc.userCommand || '', 10);
if (isNaN(age) || age < 1 || age > 120) {
bc.text = 'That does not look like an age. A number from 1 to 120:';
bc.thisIntentName = 'reg_age';
return;
}
bc.userData.age = age;
bc.text = `Done! You are ${age} years old.`;
bc.thisIntentName = null;
});
import { BotController, IUserData } from 'umbot';
interface GameData extends IUserData {
score: number;
level: number;
lastPlayed?: string;
}
// The bc annotation — TypeScript knows about the userData fields
bot.addCommand('play', ['play'], (_, bc: BotController<GameData>) => {
bc.userData.score ??= 0;
bc.userData.level ??= 1;
bc.userData.score += 10;
if (bc.userData.score % 100 === 0) bc.userData.level += 1;
bc.userData.lastPlayed = new Date().toISOString();
bc.text = `+10 points! Total: ${bc.userData.score}, level: ${bc.userData.level}`;
bc.buttons.addBtn('Again');
});
bot.addCommand('show_product', ['show product'], (_, bc) => {
if (!bc.isScreen) {
bc.text = 'This section needs a screen. Open the skill on a device with a screen.';
return;
}
bc.text = '';
bc.tts = 'Take a look at this product';
bc.card
.addOneImage('https://shop.example.com/img/1.jpg', 'iPhone 15', '99 990 ₽')
.addButton({ title: 'Buy', payload: { action: 'buy', id: 1 } });
});
import { SoundConstants } from 'umbot';
bot.addCommand('win', ['victory', 'i won'], (_, bc) => {
bc.text = 'You won!';
// The standard victory sound (Alice/Marusia only)
bc.tts = `${SoundConstants.S_EFFECT_HAMSTER}Hooray!${SoundConstants.S_EFFECT_END} Congratulations! ${SoundConstants.S_AUDIO_GAME_WIN} You are amazing!`;
});
import { Navigation, BotController, IUserData } from 'umbot';
interface ListData extends IUserData {
page?: number;
}
const items = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
const nav = new Navigation<string>(3); // 3 items per page
bot.addCommand(
'list',
['список', 'дальше', 'назад'], // Navigation recognizes the Russian "next"/"back" words
(userCommand, bc: BotController<ListData>) => {
bc.userData.page ??= 0;
nav.thisPage = bc.userData.page;
const page = nav.getPageElements(items, userCommand || '');
bc.userData.page = nav.thisPage;
bc.text = page.map((s, i) => `${i + 1}. ${s}`).join('\n');
for (const cap of nav.getPageNav()) {
bc.buttons.addBtn(cap);
}
const info = nav.getPageInfo();
if (info) bc.buttons.addBtn(info);
},
);
// Selecting an item by name is a separate command (it fires if the user said a name rather than "next")
bot.addCommand('select_item', items, (userCommand, bc: BotController<ListData>) => {
nav.thisPage = bc.userData.page ?? 0;
const selected = nav.selectedElement(items, userCommand || '', []);
if (selected) {
bc.text = `You selected: ${selected}`;
} else {
bc.text = 'There is no such item on the current page.';
}
});
For HTTP requests use the standard fetch (available in Node.js 20.19+). Always set a timeout via
AbortController — otherwise an external API can hang and eat the whole response time budget.
bot.addCommand('weather', ['weather'], async (_, bc) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
try {
const url = 'https://api.weather.example.com/current?city=moscow';
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { temp: number; condition: string };
bc.text = `It is ${data.temp}°C now, ${data.condition}`;
} catch (e) {
bc.text = 'Could not get the weather. Please try again later.';
} finally {
clearTimeout(timeout);
}
});
Authorization is a special case: the framework itself sets controller.userEvents.auth.status and controller.userToken,
so it is more convenient to keep the logic in the controller (via action) rather than in addCommand. But the trigger "the user said
'sign in'" can be a command.
// The trigger — the user initiated authorization
bot.addCommand('auth', ['sign in', 'log in'], (_, bc) => {
bc.isAuth = true; // the framework sends start_account_linking
bc.text = 'Redirecting you to authorization...';
});
// The controller handles authorization events (they arrive automatically)
bot.initBotController(
class extends BotController {
action(intentName: string | null, isCommand?: boolean, isStep?: boolean): void {
if (isCommand || isStep) return;
// Event: Alice sent account_linking_complete_event
// In THIS request userEvents.auth.status === true, but userToken is still null!
if (this.userEvents?.auth?.status === true) {
this.userData.authCompleted = true;
this.text = 'Authorization is complete! All features are now available to you.';
return;
}
// In subsequent regular requests userToken is already filled in
if (this.userToken) {
// Make authorized requests to your API:
// const res = await fetch('https://api.example.com/me', {
// headers: { Authorization: `Bearer ${this.userToken}` },
// });
}
}
},
);
Important: between step 2 (receiving
account_linking_complete_event) and step 3 (userTokenis filled in) there can be a delay — the next request from Alice. Do not count onuserTokenbeing available right away in the same request.
When the user presses a button with a payload, the framework passes the payload to controller.payload. It is more convenient to handle the press
in the controller via action() (rather than a separate command) — because the payload check must come before
the intentName check, otherwise collisions are possible.
// A button with a payload (an object is recommended)
bot.addCommand('show_product', ['show product'], (_, bc) => {
bc.card
.addOneImage('https://shop.example.com/1.jpg', 'Product 1', '99 ₽')
.addButton({ title: 'Buy', payload: { action: 'buy', id: 1 } });
});
// The controller handles button presses
bot.initBotController(
class extends BotController {
action(intentName: string | null, isCommand?: boolean, isStep?: boolean): void {
// Check the payload first — otherwise a collision: if the "Buy" button
// matches the 'buy' command slot, the command fires, isCommand=true,
// and we leave via the early return without reaching the payload.
const data = this.payload as Record<string, unknown> | null;
if (data?.action === 'buy') {
this.text = `The purchase of product #${data.id} has been initiated.`;
this.buttons.addBtn('Help');
return;
}
// If a command/step fired, it has already done everything — exit.
if (isCommand || isStep) return;
// Regular handling by intentName (welcome, help, ...)
this.buttons.addBtn('Help');
}
},
);
Tip: check the payload before intentName, otherwise collisions are possible. For example, if a button is called "Play", pressing it sets
userCommand='play', and theplayintent fires instead of your button handler.On Telegram, VK and MAX a callback button press is easier to handle with
bot.addAction('buy', cb): the adapter turns the payload'buy'or{"command":"buy"}into the action name (see the API reference).
import { Bot } from 'umbot';
import { fullPlatforms, MongoAdapter } from 'umbot/plugins';
import { rateLimiter } from 'umbot/middleware';
new Bot()
.use(fullPlatforms)
.use(new MongoAdapter({ host: 'mongodb://...', database: 'umbot' }))
.use(rateLimiter()) // globally
.use('telegram', rateLimiter(50, 120_000)) // a separate limit for TG
.start('0.0.0.0', 3000);
import express from 'express';
import { Bot } from 'umbot';
import { fullPlatforms } from 'umbot/plugins';
const bot = new Bot();
bot.use(fullPlatforms);
bot.setAppConfig({ isLocalStorage: true });
bot.initBotController(MyController);
const app = express();
// ⚠️ Do NOT add express.json(): webhookHandle reads the request body itself
app.post('/webhook', (req, res) => bot.webhookHandle(req, res));
app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() }));
app.listen(3000, () => console.log('Server started on :3000'));
import { Bot } from 'umbot';
import { fullPlatforms, T_ALISA } from 'umbot/plugins';
import { Preload } from 'umbot/preload';
const bot = new Bot();
bot.use(fullPlatforms);
bot.setAppConfig({ isLocalStorage: true });
bot.initBotController(MyController);
const preload = new Preload(bot.getAppContext());
await Promise.all([
...preload.loadImages(['./media/img1.jpg', './media/img2.png'], [T_ALISA], {
alisaSkillId: 'your-skill-id',
}),
...preload.loadSounds(['./media/win.mp3', './media/lose.mp3'], [T_ALISA], {
alisaSkillId: 'your-skill-id',
}),
]);
bot.start('0.0.0.0', 3000);
import winston from 'winston';
import { Bot } from 'umbot';
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [new winston.transports.Console()],
});
const bot = new Bot();
bot.setLogger({
log: (...args) => logger.info(args.join(' ')),
error: (msg, meta) => logger.error(msg, meta),
warn: (msg, meta) => logger.warn(msg, meta),
metric: (name, value, labels) => logger.info({ metric: name, value, labels }),
maskSecrets: true,
});
import { BotController, MiddlewareNext } from 'umbot';
import { T_ALISA, T_TELEGRAM } from 'umbot/plugins';
// A factory: one logic, different labels
const logMiddleware = (label: string) => async (ctx: BotController, next: MiddlewareNext) => {
ctx.appContext.log(`[${label}] → ${ctx.userCommand}`);
await next();
// The response is NOT built yet here: the handler runs after the whole middleware chain
ctx.appContext.log(`[${label}] ←`);
};
bot.use(logMiddleware('global'));
bot.use(T_ALISA, logMiddleware('alisa'));
bot.use(T_TELEGRAM, logMiddleware('telegram'));
Output order: [global] → → [global] ← → [alisa] → → [alisa] ← — the global chain finishes completely before
the platform chain starts. Log the response text in responseCb of bot.start() — only there is it ready.
// plugins/MyNluPlugin.ts
import { AppContext, Bot, INlu } from 'umbot';
export class MyNluPlugin {
init(appContext: AppContext, bot: Bot): void {
appContext.plugins.nlu = (
text: string,
platformNlu: INlu,
platform: string,
request: unknown,
): INlu => {
return {
...platformNlu,
intents: {
...platformNlu.intents,
custom: { slots: [] },
},
} as INlu;
};
}
// A required part of the IPlugin contract: called on bot.clearUse() / bot.close()
destroy(): void {}
}
// Usage
bot.use(new MyNluPlugin());
The i18n slot is typed as (key: string, ...params: unknown[]) => string, but in practice the framework calls it with
a single argument — the current controller.text as the key — and expects a translated string back. The call
is made by BaseBotController (the default controller) after the command runs, before the response is sent. If you
connected your own controller extending BotController, the translation will not happen — extend
BaseBotController or call the plugin yourself. Both a function and an object with a getData(key) method are
supported.
// plugins/I18nPlugin.ts
import { AppContext, Bot, createPlugin } from 'umbot';
const translations: Record<string, Record<string, string>> = {
ru: { hello: 'Привет!', bye: 'Пока!' },
en: { hello: 'Hello!', bye: 'Bye!' },
};
// The language can be determined from user or platform data — a constant here for simplicity
const lang = 'ru';
export const i18nPlugin = createPlugin((appContext: AppContext, bot: Bot): void => {
appContext.plugins.i18n = (text: string): string => {
return translations[lang]?.[text] ?? text;
};
});
// Nothing needs to be done in the controller: BaseBotController runs
// this.text through the plugin before sending the response:
// this.text = 'hello' → the user gets "Привет!"
A Telegram inline query arrives as the universal inline event. Answer it with the ready-made
answerInlineQuery() method (not via call() — that sends messages, not inline search results):
import { TelegramRequest } from 'umbot/plugins';
bot.addEvent('inline', async (ctx) => {
// The 'inline' event arrives only with inline_query in the request
const req = ctx.requestObject as { inline_query?: { id: string; query: string } };
if (!req.inline_query) return;
const telegramApi = new TelegramRequest(ctx.appContext);
await telegramApi.answerInlineQuery(req.inline_query.id, [
{
type: 'article',
id: '1',
title: 'Example',
input_message_content: { message_text: 'Hello from inline mode!' },
},
]);
ctx.skipAutoReply = true; // the response has already been sent
});
These are not shortcomings of the framework — they are simply scenarios that have no ready examples in the repository. The developer will have to implement them, building on the API.
In serverless, use bot.webhookEvent(body, headers, clientIp) instead of bot.start(): it verifies the webhook
signature and returns a ready { statusCode, body } for the cloud function. A project with a ready handler and a deployment script
is generated by npx umbot create from-flow flow.json --usecloud; a manual handler is in
Deployment.
The full flow:
this.isAuth = true.start_account_linking — Yandex opens a browser.access_token.account_linking_complete_event: true.
controller.userEvents.auth.status === true.controller.userToken is still null (the token is not passed in this request).session.user.access_token, and controller.userToken is filled in.The backend part (between steps 4–5) is not implemented in the framework — you write it yourself.
The framework caches uploaded media but does not show the remaining quota (1 GB per account). You can check it via
YandexImageRequest.checkOutPlace():
import { YandexImageRequest } from 'umbot/plugins';
// The constructor argument order: oauth, skillId, appContext.
// If you pass null instead of oauth, the token from appConfig.tokens.alisa.token is used.
const req = new YandexImageRequest(null, 'skill_id', controller.appContext);
// If needed, the token can be set explicitly (without the "OAuth " prefix —
// setOAuth adds it itself):
req.setOAuth('y0_AgAAAA...');
const res = await req.checkOutPlace();
if (res) {
// used/total come in bytes
console.log(`Used: ${res.used} / Total: ${res.total}`);
}
YANDEX.CONFIRM / YANDEX.REJECT for yes/no dialogsAlice recognizes "yes"/"no" automatically as built-in intents. You can use them without any setup in Yandex Dialogs:
public action(intentName: string | null): void {
if (this.nlu.isIntentConfirm(this.userCommand || '')) {
// the user said "да", "конечно", "хорошо" (yes, of course, okay), ...
}
if (this.nlu.isIntentReject(this.userCommand || '')) {
// "нет", "не надо", "отмена" (no, don't, cancel), ...
}
}
UsersData.save() performs an upsert (select → insert/update). If you need transactions, use model.query(cb) with
MongoAdapter:
const userData = new UsersData(this.appContext);
await userData.query(async (client, db) => {
const session = client.startSession();
await session.withTransaction(async () => {
// atomic operations
});
});
Via setLogger:
bot.setLogger({
error: (msg, meta) => Sentry.captureException(new Error(msg), { extra: meta }),
warn: (msg, meta) => Sentry.captureMessage(msg, 'warning', { extra: meta }),
// ...
});
Not part of the framework. Use bot.send(userId, text, platform) together with an external WS server.
The built-in i18n plugin is too simple. Use i18next or @formatjs/intl by connecting them to the i18n slot —
BaseBotController runs controller.text through it before sending the response (see recipe 16 about your own
controller):
import i18next from 'i18next';
import { createPlugin } from 'umbot';
bot.use(
createPlugin((appContext) => {
appContext.plugins.i18n = (text: string) => i18next.t(text, { lng: 'ru' });
}),
);
If the user sent an image URL and you want to send it — the framework can do this (just pass the URL to
card.addImage). But there is no ready function to "download an image, process it, upload it" — you need your own logic with fetch:
import { promises as fsPromises } from 'node:fs';
bot.addCommand('repost', ['repost'], async (_, bc) => {
const userUrl = bc.originalUserCommand || '';
try {
const res = await fetch(userUrl, {
signal: AbortSignal.timeout(3000), // do not let a third-party URL hang
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
const tmpPath = '/tmp/downloaded.jpg';
await fsPromises.writeFile(tmpPath, buf);
bc.card.addImage(tmpPath, 'Uploaded');
bc.text = 'Here is your image.';
} catch (e) {
bc.text = 'Could not download the image.';
}
});
A common task with no ready example. Use Navigation + card.addImage (see recipe 6 and
the section about cards in the guide).
Full reference — API v-3.1 · all versions.