umbot
Version:
Мультиплатформенный фреймворк для создания голосовых навыков и чат-ботов с единой бизнес-логикой. Встроенная поддержка ВКонтакте, Telegram, Viber, MAX, Яндекс Алисы, Маруси и Сбера SmartApp. Архитектура на адаптерах позволяет подключать любые другие платф
1,298 lines • 74.1 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Bot = void 0;
const IAppContext_1 = require("./interfaces/IAppContext");
const node_http_1 = require("node:http");
const controller_1 = require("../controller");
const AppContext_1 = require("./AppContext");
const models_1 = require("../models");
const utils_1 = require("../utils");
__exportStar(require("./interfaces/IBot"), exports);
const MAX_REQUEST_SIZE = 1024 * 1024 * 2;
function defaultSend(res, state) {
res.statusCode = state.statusCode;
const isBodyString = typeof state.body === 'string';
res.setHeader('Content-Type', isBodyString ? 'text/plain' : 'application/json');
res.end(isBodyString ? state.body : JSON.stringify(state.body));
}
function send(req, res, state, responseCb) {
if (responseCb) {
return responseCb(req, res, state);
}
return defaultSend(res, state);
}
/**
* Мультиплатформенный фреймворк для разработки голосовых навыков и чат-ботов. Он даёт единую бизнес-логику для все платформ — но одинаково эффективен, даже если вы работаете только с одной.
*
* **`Bot` — главный класс**, управляющий всем жизненным циклом приложения:
* - регистрацией платформ (Алиса, Telegram, VK, Маруся, Max и др.);
* - обработкой входящих запросов;
* - маршрутизацией команд;
* - middleware;
* - работой с базой данных;
* - логированием
* - метриками.
*
* Фреймворк построен на **адаптерах** — каждый адаптер отвечает за преобразование
* специфичного для платформы запроса в унифицированный `BotController`, который
* содержит всю информацию о пользователе, текст команды, NLU, состояние и методы ответа
* (кнопки, карточки, TTS). Вы пишете **один код**, а фреймворк доставляет его
* на все поддерживаемые платформы.
*
* ## 📖 КРАТКОЕ РУКОВОДСТВО
*
* 1. **Создайте приложение:** `const bot = new Bot();`
* 2. **Настройте токены:** `bot.setAppConfig({ tokens: { telegram: {token: '...'}} });`
* 3. **Добавьте логику при необходимости:** `bot.initBotController(MyController);`
* 4. **Добавьте команды:** `bot.addCommand('start', ['старт'], handler);`
* 5. **Запустите:** `bot.start();`
*
* ## 🎯 Ключевые возможности
* * - ✅ **Поддержка множества платформ** через подключаемые адаптеры (Алиса, Telegram, VK, Маруся и др.)
* * - ✅ **Единая логика** для голосовых навыков и ботов
* * - ✅ **Мощная система команд и интентов** с поддержкой регулярных выражений
* * - ✅ **Управление состоянием диалога** (шаги) и пользовательскими данными
* * - ✅ **Встроенная работа с БД** (MongoDB через плагины)
* * - ✅ **Middleware и плагины** для расширения функциональности
* * - ✅ **Гибкая настройка** (режимы разработки/продакшена, защита от ReDoS, кастомные резолверы команд)
* * - ✅ **Логирование и метрики** для отладки и мониторинга
*
* ## 🚀 БЫСТРЫЙ СТАРТ
* Создание простого Telegram бота
* ```ts
* import { Bot } from 'umbot';
* import { botPlatforms } from 'umbot/plugins';
*
* // 1. Создаем бота для Telegram
* const bot = new Bot();
*
* // 2. Настраиваем токен (рекомендуется через .env файл)
* bot.setAppConfig({
* env: 'local'
* });
*
* // 3. Говорим приложению, что нужно поддерживать только платформы для чат-ботов
* bot.use(botPlatforms);
*
* // 4. Добавляем команды
* bot.addCommand('help', ['помощь', 'справка'], (cmd, controller) => {
* controller.text = 'Я могу:\n• Приветствовать\n• Помогать\n• И многое другое!';
* });
*
* // 5. Запускаем сервер
* bot.start('localhost', 3000);
*
* // 6. Настройте webhook в Telegram: https://api.telegram.org/bot{YOUR_TOKEN}/setWebhook?url=https://ваш-домен/webhook
* ```
*
* Создание простого приложения со своим контроллером:
* ```ts
* const bot = new Bot();
* bot.setPlatformParams({
* intents: [{
* name: 'greeting',
* slots: ['привет', 'здравствуй']
* }]
* });
*
* class MyController extends BotController {
* public action(intentName: string | null): void {
* if (intentName === 'greeting') {
* this.text = 'Привет! Я ваш помощник 🤖';
* this.buttons
* .addBtn('Помощь')
* .addBtn('Настройки');
* }
* }
* }
*
* bot.initBotController(MyController);
* ```
* Использование с базой данных:
* ```ts
* import { Bot } from 'umbot'
* import { MongoAdapter } from 'umbot/plugins'
* const bot = new Bot();
* bot.use(new MongoAdapter({
* host: 'localhost',
* database: 'bot_db',
* user: 'user',
* pass: 'password'
* }));
* ```
*
* @template TUserData Тип пользовательских данных, по умолчанию {@link IUserData}
* @see BotController
*/
class Bot {
/** Экземпляр HTTP-сервера */
#serverInst;
/**
* Полученный запрос от пользователя.
* Может быть JSON-строкой, текстом или null
*/
_content = null;
/**
* Контекст приложения
*/
#appContext;
/**
* кастомный обработчик для определения типа платформы.
*/
#platformResolver = null;
/**
* Контроллер с бизнес-логикой приложения.
* Обрабатывает команды и формирует ответы
* @see BotControllerClass
*/
#botControllerClass;
/**
* Авторизационный токен пользователя.
* Используется для авторизованных запросов (например, в Алисе)
*/
#auth = null;
/**
* Тип платформы по умолчанию
*/
#defaultAppType = 'auto';
// Чтобы не дублировать повторное подключение к базе.
#appConnectStatus = {
isConnecting: false,
};
#globalMiddlewares = [];
#platformMiddlewares = {};
#plugins = [];
/**
* Получение корректного контроллера
* @param botController
*/
#getBotController(botController) {
if (botController) {
return botController;
}
else {
return controller_1.BaseBotController;
}
}
/**
* Создает новый экземпляр приложения
*
* @param {TAppType} [type] - Тип платформы (по умолчанию автоопределение)
* @param {TBotControllerClass} [botController] - Контроллер с логикой
*
* @throws {Error} Если не удалось инициализировать тип платформы
*
* @example
* ```ts
* // Создание навыка для Алисы
* const bot = new Bot(T_ALISA, MyController);
*
* // Создание бота для Telegram
* const bot = new Bot(T_TELEGRAM, MyController);
*
* // Создание бота для VK
* const bot = new Bot(T_VK, MyController);
*
* // Создание приложения по умолчанию
* const bot = new Bot();
* ```
*/
constructor(type, botController) {
this.#botControllerClass = this.#getBotController(botController);
this.#appContext = new AppContext_1.AppContext();
this.#defaultAppType = type || AppContext_1.T_AUTO;
}
/**
* Явно устанавливает тип платформы для всего приложения. Стоит использовать в крайнем случае
* @param appType
*/
set appType(appType) {
this.#defaultAppType = appType;
}
/**
* Возвращает установленный тип приложения.
*/
get appType() {
return this.#defaultAppType;
}
/**
* Задает режим работы с регулярным выражениями.
* При значении auto, регулярные выражения будут группироваться в группу, благодаря чему уменьшается время обработки. Логика начинает отрабатывать после того, как добавили более 300 команд.
* При значении no-group, группировка регулярных выражений производиться не будет, из-за чего каждое регулярное выражение будет обрабатываться отдельно. Указывать данное значение стоит в том случае, если вы получаете сильную деградацию при обработке групп.
* При значении group, все регулярные выражения будут добавляться в группу. Перед использованием данного значения, перепроверьте производительность, так как при группировке определенных регулярных выражений, производительность может быть ниже.
* @param mode - Определяет режим работы с регулярными выражениями.
*/
setCommandGroupMode(mode) {
this.#appContext.command.setCommandGroupMode(mode);
return this;
}
/**
* Устанавливает кастомный обработчик для определения типа платформы.
*
* По умолчанию фреймворк определяет платформу автоматически, перебирая зарегистрированные адаптеры
* и вызывая их метод `isPlatformOnQuery`. Этот метод позволяет переопределить логику определения,
* что полезно в следующих случаях:
* - Нужно добавить поддержку кастомной платформы, не создавая адаптер.
* - Требуется изменить стандартное поведение для конкретного набора запросов
* (например, по заголовкам или по содержимому запроса).
* - Необходимо выполнить какую-то логику до того, как запрос попадёт в адаптер.
*
* **Важно:** резолвер выполняется **до** вызова `isPlatformOnQuery` любого адаптера.
* Если резолвер возвращает строку (имя платформы), фреймворк использует это значение
* и не выполняет стандартное автоопределение. Если возвращает `null`, то запускается
* стандартный перебор адаптеров.
*
* В резолвер передаётся опциональная функция `detect`, которая вызывает встроенное
* автоопределение. Это позволяет, например, получить результат стандартного определения
* и затем скорректировать его.
*
* @param resolver - Функция, принимающая запрос, заголовки и опционально `detect`.
* Должна вернуть имя платформы (строка) или `null`.
* @returns Тот же экземпляр приложения для цепочечных вызовов.
*
* @example
* // Простейший резолвер, который для всех запросов использует платформу 'alisa'
* bot.setPlatformResolver(() => 'alisa');
*
* @example
* // Резолвер, который вызывает стандартное определение и при необходимости
* // заменяет результат для конкретного заголовка.
* bot.setPlatformResolver((query, headers, detect) => {
* const platform = detect?.(query, headers);
* if (platform === 'telegram' && headers?.['x-force-vk']) {
* return 'vk';
* }
* return platform;
* });
*
* @example
* // Полное переопределение: обработка запросов от собственного сервиса.
* bot.setPlatformResolver((query) => {
* if (typeof query === 'object' && query?.source === 'my_service') {
* return 'my_platform';
* }
* return null;
* });
*
* @remarks
* - Резолвер может быть асинхронным, если это необходимо. Для этого просто объявите
* функцию как `async` и возвращайте `Promise<string | null>`. Фреймворк дождётся
* результата перед продолжением.
* - Внутри резолвера можно модифицировать `query` или `headers`, если нужно повлиять
* на дальнейшую обработку (например, добавить недостающие поля).
* - Если резолвер возвращает имя платформы, для которой нет зарегистрированного
* адаптера, фреймворк выбросит ошибку. Убедитесь, что нужный адаптер подключен.
*/
setPlatformResolver(resolver) {
this.#platformResolver = resolver;
return this;
}
/**
* Позволяет установить свою реализацию для логирования
* @param logger
*/
setLogger(logger) {
this.#appContext.setLogger(logger);
return this;
}
/**
* Регистрирует команду — обработчик, срабатывающий при совпадении входящего текста с одним из шаблонов.
*
* Поиск команд оптимизирован:
* 1. Сначала проверяется точное совпадение
* 2. Если точного совпадения нет — выполняется последовательный перебор в порядке регистрации
*
* Первая совпавшая команда выполняется.
*
* @param {string} commandName - Уникальное имя команды (например, `'greeting'`). Используется для логирования и отладки.
* @param {TSlots} slots - Массив шаблонов для сопоставления:
* - Если элемент — строка → ищется как подстрока (`text.includes(...)`).
* - Если элемент — RegExp → проверяется как регулярное выражение (`.test(text)`).
* - Параметр `isPattern` учитывается **только если в `slots` нет RegExp**.
* - При наличии хотя бы одного `RegExp`, `isPattern = false` игнорируется, и каждый элемент
* обрабатывается согласно своему типу.
* @param {ICommandParam['cb']} cb - Обработчик команды. Принимает:
* - `text` — исходный текст от пользователя;
* - `controller` — экземпляр `BotController` для формирования ответа (кнопки, текст, шаги, данные и т.д.);
*
* Поддерживает `async`.
* @param {boolean} isPattern - Если `true` и в `slots` **нет RegExp**, все строки преобразуются в регулярные выражения.
* ⚠️ Используйте с осторожностью: возможен ReDoS. Все RegExp проверяются на уязвимости.
*
* @example
* Простая текстовая команда:
* ```ts
* bot.addCommand(
* 'greeting',
* ['привет', 'здравствуй'],
* (cmd, ctrl) => {
* ctrl.text = 'Здравствуйте!';
* }
* );
* ```
*
* @example
* Команда с регулярными выражениями:
* ```ts
* // Обработка чисел от 0 до 999
* bot.addCommand(
* 'number',
* ['\\b(\\d{0,3})\\b'],
* (cmd, ctrl) => {
* ctrl.text = `Вы ввели число: ${cmd}`;
* },
* true // включаем поддержку регулярных выражений
* );
* ```
*
* @example
* Команда с доступом к состоянию:
* ```ts
* bot.addCommand(
* 'stats',
* ['статистика'],
* async (cmd, ctrl) => {
* if (ctrl) {
* // Доступ к пользовательским данным
* const visits = ctrl.userData?.visits || 0;
* ctrl.text = `Вы использовали приложение ${visits} раз`;
*
* // Доступ к кнопкам и другим UI элементам
* ctrl.buttons
* .addBtn('Сбросить статистику')
* .addBtn('Закрыть');
* }
* }
* );
* ```
*
* @example
* // Асинхронная команда (работа с API):
* ```ts
* bot.addCommand('weather', ['погода'], async (text, controller) => {
* const weather = await fetch('Какой-то сервис для получения погоды');
* controller.text = `Погода: ${await weather.text()}`;
* });
* ```
*
* @example
* // Fallback: срабатывает, если ни одна команда не подошла:
* ```ts
* bot.addCommand('*', [], (text, controller) => {
* controller.text = `Извините, я не понял "${text}". Скажите "помощь" для списка команд.`;
* });
* ```
*
* @remarks
* Поиск команд оптимизирован:
* 1. Сначала проверяется точное совпадение
* 2. Если точного совпадения нет — выполняется последовательный перебор
*
* При регистрации более 300 команд с регулярными выражениями
* фреймворк автоматически объединяет их в группы для повышения производительности.
*
* При isPattern=true используются регулярные выражения JavaScript
* В callback доступен весь функционал BotController
* Можно использовать async функции в callback
*/
addCommand(commandName, slots, cb, isPattern = false) {
this.#appContext.command.addCommand(commandName, slots, cb, isPattern);
return this;
}
/**
* Удаляет зарегистрированную команду по имени
* @param commandName - Имя команды
*/
removeCommand(commandName) {
this.#appContext.command.removeCommand(commandName);
return this;
}
/**
* Удаляет **все** зарегистрированные команды
*
* > ⚠️ Это **глобальная операция**: все сценарии станут недоступны.
* > Используйте с осторожностью (например, при перезагрузке логики приложения).
*/
clearCommands() {
this.#appContext.command.clearCommands();
return this;
}
/**
* Регистрирует обработчик для именованного шага диалога.
*
* Шаг — это часть **многошагового сценария** (например: "регистрация", "оформление заказа").
* После вызова `ctx.thisIntentName = 'myStep'` в команде или другом шаге,
* следующее сообщение пользователя будет обработано этим обработчиком.
*
* > 💡 Обработчик получает полный `BotController`, как и в командах:
* > доступны `this.text`, `this.userData`, `this.buttons` и т.д.
*
* @param stepName — Уникальное имя шага (например, `'enter_email'`).
* @param handler — Функция, вызываемая при получении сообщения в этом шаге.
* @returns Текущий экземпляр `Bot` (для цепочки вызовов).
*
* @example
* ```ts
* bot.addCommand('start', ['начать'], (_, ctx) => {
* ctx.text = 'Готовы начать приключение?';
* ctx.buttons.addBtn('Да').addBtn('Нет');
* ctx.thisIntentName = 'confirm';
* });
* bot.addStep('confirm', (ctx) => {
* if (ctx.userCommand === 'да') {
* ctx.text = 'Отлично! Добро пожаловать.';
* } else {
* ctx.text = 'Извините, вход запрещён.';
* ctx.thisIntentName = 'goodbye'; // переходим к другому шагу
* }
* });
* ```
* @example
* ```ts
* // Пример многошаговой формы
* bot.addCommand('order', ['заказать'], (_, ctx) => {
* ctx.text = 'Введите ваше имя:';
* ctx.thisIntentName = 'step_name';
* });
*
* bot.addStep('step_name', (ctx) => {
* ctx.userData.name = ctx.userCommand;
* ctx.text = `Приятно познакомиться, ${ctx.userData.name}! Теперь введите email:`;
* ctx.thisIntentName = 'step_email';
* });
*
* bot.addStep('step_email', (ctx) => {
* ctx.userData.email = ctx.userCommand;
* ctx.text = `Заказ оформлен! Имя: ${ctx.userData.name}, Email: ${ctx.userData.email}`;
* ctx.thisIntentName = null; // сбрасываем шаг
* });
* ```
*/
addStep(stepName, handler) {
this.#appContext.command.addStep(stepName, handler);
return this;
}
/**
* Удаляет зарегистрированный шаг по имени.
*
* После удаления шаг больше не будет обрабатываться, даже если активен у пользователя.
* (Рекомендуется завершать активные сценарии через `ctx.clearStep()` перед удалением.)
*
* @param stepName — Имя шага для удаления.
* @returns Текущий экземпляр `Bot`.
*/
removeStep(stepName) {
this.#appContext.command.removeStep(stepName);
return this;
}
/**
* Удаляет **все** зарегистрированные шаги.
*
* > ⚠️ Это **глобальная операция**: все сценарии станут недоступны.
* > Используйте с осторожностью (например, при перезагрузке логики приложения).
*
* @returns Текущий экземпляр `Bot`.
*/
clearSteps() {
this.#appContext.command.clearSteps();
return this;
}
/**
* Удаляет **все** зарегистрированные платформы, плагины и middleware службы.
*
* > ⚠️ Это **глобальная операция**: все сценарии станут недоступны.
* > Используйте с осторожностью (например, при перезагрузке логики приложения).
*
* @returns Текущий экземпляр `Bot`.
*/
clearUse() {
this.#appContext.platforms = {};
this.#globalMiddlewares = [];
this.#platformMiddlewares = {};
this.#appContext.plugins = {};
this.#plugins.forEach((plugin) => {
if (typeof plugin === 'function') {
plugin(this);
}
else {
plugin.destroy?.(this)?.catch((e) => {
this.#appContext.logError(`Произошла ошибка при уничтожении адаптера! Текст ошибки: ${e.message}`, {
error: e,
});
});
}
});
this.#plugins = [];
return this;
}
/**
* Устанавливает режим работы приложения
*
* @param {'dev' | 'prod' | 'strict_prod'} appMode - Режим работы:
* - 'dev': подробные логи, отладочная информация, отключена проверка ReDoS
* - 'prod': минимальные логи, производительность, НО небезопасные RegExp всё равно регистрируются
* - 'strict_prod': строгая проверка безопасности — любая RegExp с потенциальным ReDoS отклоняется с ошибкой
*
* ⚠️ ВАЖНО: В продакшене всегда используйте 'strict_prod' для защиты от атак через регулярные выражения.
* Режим 'prod' оставлен для обратной совместимости, но небезопасен.
*
* @example
* // Для продакшена (обязательно!)
* bot.setAppMode('strict_prod');
*
* // Для разработки
* bot.setAppMode('dev');
* @param appMode
*/
setAppMode(appMode) {
this.#appContext.appMode = appMode;
this.#appContext.command.strictMode = appMode === 'strict_prod';
return this;
}
/**
* Позволяет заменить встроенный механизм сопоставления команд на **кастомный алгоритм поиска**.
*
* По умолчанию `umbot` использует **оптимизированный поиск**:
* 1. Сначала проверяется точное совпадение
* 2. Если точного совпадения нет — выполняется последовательный перебор с поддержкой:
* - подстрок (`includes`),
* - простых регулярных выражений.
*
* Это обеспечивает **предсказуемость**, **простоту отладки** и **соответствие поведению большинства платформ**:
* первая совпавшая команда (в порядке регистрации) — выигрывает.
*
* Однако при:
* - количестве команд >1000,
* - высокой нагрузке (>1000 RPS),
* - необходимости в fuzzy-поиске или сложной маршрутизации,
* вы можете подключить оптимизированный resolver.
*
* @param resolver - Функция вида `(userText: string, commands: Map<string, ICommand>) => string | null`.
* Должна вернуть имя команды или `null`, если совпадений нет.
*
* @example
* ```ts
* // Пример: кэширование частых запросов
* const cache = new Map<string, string | null>();
* bot.setCustomCommandResolver((text, commands) => {
* if (cache.has(text)) return cache.get(text)!;
*
* for (const [name, cmd] of commands) {
* if (cmd.slots && cmd.slots.some(slot => typeof slot === 'string' && text.includes(slot))) {
* cache.set(text, name);
* return name;
* }
* }
* cache.set(text, null);
* return null;
* });
* ```
*
* @remarks
* **Рекомендации при реализации resolver'а:**
* - Сохраняйте **порядок регистрации команд**, если логика зависит от приоритета.
* - Используйте **кэширование** для часто встречающихся фраз (но учитывайте потребление памяти).
* - Для fuzzy-поиска — рассмотрите `fuse.js`, `natural` или trie-структуры.
* - При работе с регулярными выражениями **обязательно проверяйте их на ReDoS**.
* - Избегайте тяжёлых синхронных операций — они блокируют event loop.
*/
setCustomCommandResolver(resolver) {
this.#appContext.command.customCommandResolver = resolver;
return this;
}
/**
* Задаёт **инфраструктурную конфигурацию** приложения: подключение к БД, загрузку `.env`, и другие
* настройки, связанные с окружением выполнения (а не с бизнес-логикой приложения).
*
* > 🔒 **Безопасность**: никогда не храните секреты (пароли, токены, API-ключи) прямо в коде.
* > Всегда используйте `.env`-файлы или переменные окружения.
*
* @param {IAppConfig} config - Конфигурация приложения
*
* @example
* ```ts
* // Конфигурация с базой данных
* bot.setAppConfig({
* db: {
* host: 'localhost',
* database: 'bot_db',
* user: 'user',
* pass: 'password'
* }
* });
*
*
* @remarks
* Важно! Чувствительные данные рекомендуется сохранять в .env файл, передав путь к нему:
* ```ts
* bot.setAppConfig({
* env: './.env', // путь до файла
* });
* ```
*/
setAppConfig(config) {
if (config) {
this.#appContext.setAppConfig(config);
}
return this;
}
/**
* Возвращает контекст приложения — центральный объект для расширенной настройки фреймворка.
*
* **Когда использовать:**
* - Замена HTTP-клиента: `bot.getAppContext().httpClient = customFetch`
* - Доступ к зарегистрированным адаптерам: `bot.getAppContext().platforms`
* - Настройка метрик и логирования
*
* **Пример:**
* ```ts
* // Добавление таймаутов к запросам
* const customFetch: THttpClient = async (url, init) => {
* const controller = new AbortController();
* const timeout = setTimeout(() => controller.abort(), 5000);
* return fetch(url, { ...init, signal: controller.signal });
* };
*
* bot.getAppContext().httpClient = customFetch;
*
* ⚠️ Важно: Не модифицируйте внутренние поля контекста напрямую (например, commands, steps). Используйте публичные методы addCommand(), addStep().
*/
getAppContext() {
return this.#appContext;
}
/**
* Задаёт параметры, управляющие **логикой приложения** на всех платформах.
*
* Сюда входят:
* - список интентов по умолчанию (`help`, `welcome` и др.),
* - тексты ответов по умолчанию (`welcome_text`, `help_text`, `empty_text`),
* - другие настройки, не зависящие от конкретной платформы
*
* @param {IAppParam} params - Параметры платформы
*
* @example
* ```ts
* // Базовая настройка
* bot.setPlatformParams({
* intents: [{
* name: 'help',
* slots: ['помощь', 'справка']
* }],
* welcome_text: 'Привет! Я ваш помощник.',
* help_text: 'Скажите "помощь", чтобы увидеть команды.',
* empty_text: 'Извините, я не понял.'
* });
* ```
*/
setPlatformParams(params) {
if (params) {
this.#appContext.setPlatformParams(params);
}
return this;
}
/**
* Устанавливает контроллер, метод `action()` которого вызывается **после обработки каждого запроса** —
* независимо от того, была ли распознана команда, активен ли шаг сценария, или обработан базовый интент.
*
* Метод `action()` получает флаги `isCommand` и `isStep`, чтобы вы могли:
* - добавить общую логику (аналитика, логирование, трассировка);
* - модифицировать ответ глобально (например, добавить рекламу, кнопку "Оценить");
* - применить кросс-функциональные правила (rate limiting, мутации текста и т.д.);
*
* > 💡 **Рекомендация**: основную бизнес-логику размещайте в командах (`addCommand`) и шагах (`addStep`),
* > а в `action()` — только **сквозную** логику, которую не хочется дублировать.
*
* Контроллер по умолчанию уже обрабатывает интенты `welcome`, `help` и `fallback`,
* но вы можете заменить его, если нужно кастомное поведение.
*
* @param {BotController<TUserData>} fn - Экземпляр контроллера, наследующий `BotController<TUserData>`
*
* @example
* ```ts
* class MyController extends BotController {
* public action(intentName: string): void {
* switch (intentName) {
* case 'greeting':
* this.text = 'Привет!';
* break;
* case 'help':
* this.text = 'Чем могу помочь?';
* break;
* }
* }
* }
*
* bot.initBotController(MyController);
* ```
*
* @example
* ```ts
* // Добавление рекламы ко всем ответам
* class MyController extends BotController {
* public action(
* intentName: string | null,
* isCommand: boolean = false,
* isStep: boolean = false
* ): void {
* // Добавим кнопку "Подписаться" везде, кроме шагов
* if (!isStep) {
* this.buttons.addBtn('Подписаться на рассылку', 'http://localhost');
* }
*
* // Логирование
* console.log(`Обработано: ${isCommand ? 'команда' : isStep ? 'шаг' : 'интент'} – ${intentName}`);
* }
* }
*
* bot.initBotController(MyController);
* ```
*/
initBotController(fn) {
if (fn) {
this.#botControllerClass = fn;
}
return this;
}
/**
* Устанавливает контент запроса.
* Используется для передачи данных от пользователя в платформу.
* Не рекомендуется использовать напрямую, использовать только в крайнем случае, либо для тестов
*
* @param {TBotContent} content - Контент запроса
*
* @example
* ```ts
* // Установка текстового сообщения
* bot.setContent('Привет!');
*
* // Установка JSON-данных
* bot.setContent({
* request: {
* command: 'привет',
* original_utterance: 'Привет, мир!'
* }
* });
* ```
*/
setContent(content) {
this._content = content;
}
/**
* Очищает состояние пользователя
*/
_clearState(botController) {
if (botController) {
botController.clearStoreData();
}
}
#defaultPlatformDetect(uBody, headers) {
if (this.#defaultAppType && this.#defaultAppType !== AppContext_1.T_AUTO) {
return this.#defaultAppType;
}
if (this.#appContext.platforms) {
for (const platformName in this.#appContext.platforms) {
if (this.#appContext.platforms[platformName].isPlatformOnQuery(uBody, headers)) {
return this.#appContext.platforms[platformName].platformName;
}
}
}
return null;
}
/**
* Определяет тип приложения по заголовкам или телу запроса
* @param uBody - Тело запроса
* @param headers - Заголовки запроса
*/
#getAppType(uBody, headers) {
if (this.#platformResolver) {
const customType = this.#platformResolver(uBody, headers, this.#defaultPlatformDetect.bind(this, uBody, headers));
if (customType !== null) {
return customType;
}
}
return this.#defaultPlatformDetect(uBody, headers);
}
#initNLU(botController) {
if (this.#appContext.plugins.nlu) {
const nlu = typeof this.#appContext.plugins.nlu === 'function'
? this.#appContext.plugins.nlu(botController.userCommand || botController.originalUserCommand || '', botController.nlu.getNluValue(), botController.appType, botController.requestObject)
: this.#appContext.plugins.nlu.getData(botController.userCommand || botController.originalUserCommand || '', botController.nlu.getNluValue(), botController.appType, botController.requestObject);
botController.nlu.setNlu(nlu, true);
}
}
async #getDbAdapter(dbAdapter) {
if (!this.#appContext.database.isSendConnect) {
if (this.#appConnectStatus.isConnecting) {
if ((0, utils_1.isPromise)(this.#appConnectStatus.status)) {
await this.#appConnectStatus.status;
}
}
else {
this.#appConnectStatus.isConnecting = true;
const res = (this.#appConnectStatus.status = dbAdapter.connect());
if ((0, utils_1.isPromise)(res)) {
await res;
}
this.#appContext.database.isSendConnect = res;
}
}
return dbAdapter;
}
/* eslint-disable require-atomic-updates*/
async #initUserData(botController, userData, localStateData) {
if (botController.platformOptions.usedLocalStorage) {
botController.state = localStateData;
}
if (userData && !this.#appContext.appConfig.isLocalStorage) {
const query = {
userId: botController.userId,
};
if (this.#auth) {
query.userId = userData.escapeString(botController.userToken);
}
if (await userData.whereOne(query)) {
botController.userData = userData.data;
return false;
}
else {
if (!botController.userData) {
botController.userData = {};
}
userData.userId = botController.userId;
userData.meta = botController.userMeta;
}
}
return true;
}
/**
* Запуск логики приложения
* @param botController - Контроллер с бизнес-логикой приложения
* @param platformClass - Класс платформенного адаптера, который будет подготавливать корректный ответ в зависимости от платформы
* @param appType - Тип приложения
*/
async #runApp(botController, platformClass, appType) {
const dbAdapter = this.#appContext.database.adapter
? await this.#getDbAdapter(this.#appContext.database.adapter)
: undefined;
let userData;
if (dbAdapter) {
userData = new models_1.UsersData(this.#appContext);
botController.userId = userData.escapeString(botController.userId);
userData.platform = platformClass.platformName;
}
botController.platformOptions.usedLocalStorage =
platformClass.isLocalStorage(botController);
const isLocalStorage = this.#appContext.appConfig.isLocalStorage &&
botController.platformOptions.usedLocalStorage;
let isNewUser = true;
let localStateData = platformClass.getLocalStorage(botController);
if ((0, utils_1.isPromise)(localStateData)) {
localStateData = await localStateData;
}
if (isLocalStorage) {
botController.userData = localStateData;
}
else {
isNewUser = await this.#initUserData(botController, userData, localStateData);
}
this.#initNLU(botController);
const shouldProceed = this.#globalMiddlewares.length || this.#platformMiddlewares[appType]?.length
? await this.#runMiddlewares(botController, appType)
: true;
const content = shouldProceed
? await this.#getAppContent(botController, platformClass, appType)
: 'Request entity too large';
if (userData &&
!(isLocalStorage &&
(!botController.state || botController.state === botController.userData))) {
userData.userId = botController.userId;
userData.data = botController.userData;
if (isNewUser) {
await userData.save(true).then((res) => {
if (!res) {
this.#appContext.logError(`Bot:run(): Произошла ошибка при сохранении данных для нового пользователя "${botController.userId}".`);
}
return res;
});
}
else {
await userData.update().then((res) => {
if (!res) {
this.#appContext.logError(`Bot:run(): Произошла ошибка при сохранении данных для пользователя: "${botController.userId}".`);
}
return res;
});
}
}
if (botController.platformOptions.error) {
this.#appContext.logError(botController.platformOptions.error);
}
if (this.#$botController) {
this._clearState(botController);
}
return content;
}
#setOldIntentName(botController) {
if (!botController.oldIntentName &&
botController.userData &&
botController.userData.oldIntentName) {
botController.oldIntentName = botController.userData.oldIntentName;
}
else if (!botController.oldIntentName &&
botController.state &&
typeof botController.state === 'object' &&
botController.state.oldIntentName) {
botController.oldIntentName = botController.state.oldIntentName;
}
}
async #getPlatformContent(botController, platformClass) {
let userDataLength = (0, utils_1.keysCount)(botController.userData);
if (botController.thisIntentName !== null) {
if (botController.state && userDataLength === 0) {
botController.state.oldIntentName = botController.thisIntentName;
}
else {
botController.userData.oldIntentName = botController.thisIntentName;
}
}
else {
// В Алисе в любом случае будет какое-то поле, так как если ничего не будет, то данные просто не обновятся.
// Поэтому в oldIntentName в любом случае необходимо писать null
if (botController.userData.oldIntentName !== undefined) {
userDataLength--;
botController.userData.oldIntentName = null;
}
if (botController.state?.oldIntentName !== undefined) {
botController.state.oldIntentName = null;
}
}
let stateData;
if (this.#appContext.appConfig.isLocalStorage &&
botController.platformOptions.usedLocalStorage) {
if (this.#appContext.database.adapter) {
stateData =
botController.state && (0, utils_1.keysCount)(botController.state)
? botController.state
: botController.userData;
}
else {
stateData = userDataLength ? botController.userData : botController.state;
}
}
else if (botController.state && (0, utils_1.keysCount)(botController.state)) {
stateData = botController.state;
}
let content;
if (botController.isSendRating) {
content = platformClass.getRatingContext(botController);
}
else {
if (botController.state && userDataLength === 0) {
botController.userData = botController.state;
}
content = platformClass.getContent(botController, stateData);
}
if (botController.platformOptions.usedLocalStorage) {
const res = platformClass.setLocalStorage(stateData, botController);
if (res) {
await res;
}
}
return content;
}
/* eslint-enable require-atomic-updates*/
async #getAppContent(botController, platformClass, appType) {
this.#setOldIntentName(botController);
const res = botController.run();
if (res) {
await res;
}
if (botController.tts === null && this.#appContext.platforms[appType]?.isVoice) {
botController.tts = botController.text;
}
return this.#getPlatformContent(botController, platformClass);
}
/**
* Регистрирует middleware или плагин (например, адаптер платформы).
*
* Поддерживаются три варианта:
* - `use(middleware)` — глобальный middleware для всех платформ.
* - `use(platform, middleware)` — middleware только для указанной платформы.
* - `use(plugin)` — подключает плагин (объект с `init()` или функцию с `isPlugin: true`).
*
* Middleware имеет доступ к `BotController` и может:
* - модифицировать контекст (`text`, `userData`, `buttons` и т.д.),
* - прервать обработку (если не вызвать `next()`),
* - выполнять логирование, проверки, tracing и др.
*
* Плагин получает `AppContext` при инициализации и может регистрировать
* middleware, команды или другую логику.
*
* Метод поддерживает цепочку вызовов.
*/
use(arg1, arg2) {
if (typeof arg1 === 'function') {
if (arg1.isPlugin) {
const fn = arg1(this.#appContext, this);
if (fn) {
this.#plugins.push(fn);
}
}
else {
this.#globalMiddlewares.push(arg1);
}
return this;
}
if (typeof arg1 !== 'string') {
arg1.init(this.#appContext, this);
this.#plugins.push(arg1);
}
else if (arg2) {
this.#platformMiddlewares[arg1] ??= [];
this.#platformMiddlewares[arg1].push(arg2);
}
return this;
}
/**
* Выполняет middleware для текущего запроса
* @param controller
* @param appType
*/
async #runMiddlewares(controller, appType) {
if (appType) {
if (this.#globalMiddlewares.length === 0 &&
!this.#platformMiddlewares[appType]?.length) {
return true;
}
const start = this.#appContext.usedMetric ? performance.now() : 0;
let index = 0;
let isEnd = false;
try {
let middlewares = this.#globalMiddlewares;
const next = async () => {
if (index < middlewares.length) {
const mw = middlewares[index++];
await mw(controller, next);
}
else {
isEnd = true;
}
};
// Запускаем цепочку
await next();
if (isEnd && this.#platformMiddlewares[appType]?.length) {
isEnd = false;
index = 0;
middlewares = this.#platformMiddlewares[appType];
await next();
}
}
catch (err) {
this.#appContext.logError(`Bot:runMiddlewares: Произошла ошибка при обработке middleware. Текст ошибки: ${err.message}`, {
error: err,
});
}
if (this.#appContext.usedMetric) {
this.#appContext.logMetric(IAppContext_1.EMetric.MIDDLEWARE, performance.now() - start, {
platform: appType,
});
}
return isEnd;
}
return true;
}
#$botController = null;
/**
* Установка контроллера. Используется только для тестирования
* @param botController
* @protected
*/
_setBotController(botController) {
this.#$botController = botController;
}
/**
* Получение контроллера приложения
* Не использовать!
* @private
*/
getBotController() {
return this.#$botController;
}
/**
* Выполняет непосредственную обработку входящего запроса от платформы.
* Этот метод **не запускает HTTP-сервер** и **не обрабатывает HTTP-запросы напрямую** —
* он принимает уже распарсенные данные и возвращает результат обработки.
*
* Обычно вызывается **внутри {@link webhookHandle}**, но может использоваться напрямую,
* если вы реализуете собственный обработчик запросов, тестируете логику приложения
* или запускаете его вне HTTP-контекста (например, из консоли или очереди сообщений).
*
* @param {TAppType | null} [appType] - Тип приложения. Если не указан, будет определен автоматически в зависимости от запроса.
* @param {string | object} [content] - Входные данные для обработки (например, текст сообщения или объект запроса).
* @returns {Promise<TRunResult>} Результат обработки запроса
* @throws {Error} Если не удаётся определить платформу или отсутствуют данные для обработки.
*
* @example
* ```ts
* // Обработка запроса
* const result = await bot.run();
* console.log(result);
* ```
*/
async run(appType = null, content = null) {
if (!this.#botControllerClass) {
const errMsg = 'Не определен класс с логикой приложения. Укажите класс контроллер, передав его в метод initBotController';
this.#appContext.logError(errMsg);
throw new Error(errMsg);
}
let correctContent = this._content || content;
if (correctContent && typeof correctContent === 'string') {
correctContent = JSON.parse(correctContent);
}
if (!correctContent) {
const msg = `${appType ? `Для платформы "${appType}"` : 'Пришел не корректный запрос в котором'} передано пустое содержимое, дальнейшая обработка невозможна.`;
this.#appContext.logError(msg);
throw new Error(msg);
}
const botController = this.#$botController || new this.#botControllerClass(this.#appContext);
if (this.#$botController) {
botController.setAppContext(this.#appContext);
}
botController.appType = appType || this.#getAppType(correctContent);
const platformClass = botController.appType
? this.#appContext.platforms[botController.appType]
: null;
if (platformClass) {
botController.userToken ??= this.#auth;
platformClass.updateTimeStart(botController);
let res = platformClass.setQueryData(correctContent, botController);
if ((0, utils_1.isPromise)(res)) {
res = await res;
}
if (res) {
if (botController.platformOptions.sendInInit) {
return botController.platformOptions.sendInInit;
}
return this.#runApp(botController, platformClass, botController.appType);
}
else {
this.#appContext.logError(botController.platformOptions.error);
throw new Error(botController.platformOptions.error || '');
}
}
else {
const msg = 'Не удалось определить платформу, от которой пришел запрос. Дальнейшая обработка невозможна.';
this.#appContext.logError(msg);
throw new Error(msg);
}
}
#isWebhookError(req, res, responseCb) {
if (req.method !== 'POST') {
this.#webhookHandleError(req, res, 400, responseCb);
return true;
}
const contentLength = req.headers['content-length'];
if (contentLength && parseInt(contentLength) > MAX_REQUEST_SIZE) {
this.#webhookHandleError(req, res, 413, responseCb);
return true;
}
return false;
}
#webhookHandleError(req, res, code, responseCb) {
let body = 'Bad Request';
let statusCode = code;
switch (code) {
case 400:
body = 'Empty request';
break;
case 422:
statusCode = 400;
body = 'Invalid JSON';
break;
case 413:
body = 'Request entity too large';
break;
case 401:
body = 'Invalid token';
break;
case 404:
body = 'Not found';
break;
case 500:
body = 'Internal Server Error';
break;
}
return send(req, res, {
statusCode,
body,
defaultSend,
}, responseCb);
}
/**
* Обрабатывает входящий webhook-запрос от поддерживаемой платформы (Telegram, VK, Алиса и др.).
* Метод автоматически распознаёт платформу по заголовкам или телу запроса и делегирует обработку
* соответствующему адаптеру. Ответ отправляется автоматически через переданный объект `res`.
*
* @param req - Объект входящего запроса (IncomingMessage или совместимый)
* @param res - Объект ответа (ServerResponse или совместимый)
* @param responseCb - Callback, для пользовательской обработки ответа пользователю. Стоит использовать в том случае, если есть необходимость переопределить стандартный ответ фреймворка.
* Если передан, ВЫ ДОЛЖНЫ вызвать res.end() самостоятельно.
* Без колбэка фреймворк автоматически завершит ответ через res.end().
*
* @example
* ```ts
* // Использование с Express
* import express from 'express';
* const app = express();
* app.use(express.json());
*
* const bot = new Bot();
*
* app.post('/webhook', (req, res) => bot.webhookHandle(req, res));
* ```
* @example
* // Использование с встроенным HTTP-сервером Node.js
* import { createServer } from 'http';
*
* const bot = new Bot();
* const server = createServer((req, res) => {
* if (req.method === 'POST' && req.url === '/webhook') {
* bot.webhookHandle(req, res);
* } else {
* res.statusCode = 404;
* res.end();
* }
* });
* server.listen(3000);
* @example
* ```ts
* // Пример с переопределением ответа
* import { createServer } from 'http';
*
* const bot = new Bot();
* const server = createServer((req, res) => {
* if (req.method === 'POST' && req.url === '/webhook') {
* // В случае если вернулся статус отличный от 200, вернет содержимое какой-то страницы.
* bot.webhookHandle(req, res, (_reg: IncomingMessage, _res: ServerResponse, state: IBotResponseState) => {
* if (state.statusCode === 200) {
* return state.defaultSend(_res, state);
* }
* res.statusCode = 200;
* res.end(...);// Какое-то содержимое страницы
* });
* } else {
*