UNPKG

umbot

Version:

Мультиплатформенный фреймворк для создания голосовых навыков и чат-ботов с единой бизнес-логикой. Встроенная поддержка ВКонтакте, Telegram, Viber, MAX, Яндекс Алисы, Маруси и Сбера SmartApp. Архитектура на адаптерах позволяет подключать любые другие платф

1,298 lines 74.1 kB
"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 { *