UNPKG

umbot

Version:

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

589 lines (588 loc) 27.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.CommandReg = exports.FALLBACK_COMMAND = void 0; const RegExp_1 = require("../../utils/standard/RegExp"); const utils_1 = require("./utils"); const os_1 = __importDefault(require("os")); const LIMIT_COMMANDS = [1e4, 5e4, 1e5]; // Глобальные лимиты, возможно, можно вынести в конфигурацию let MAX_COUNT_FOR_GROUP = 0; let MAX_COUNT_FOR_REG = 0; /** * Устанавливает ограничение на использование активных регулярных выражений. Нужен для того, чтобы приложение не падало под нагрузкой. */ function setMemoryLimit() { const total = os_1.default.totalmem(); // re2 гораздо лучше работает с оперативной память, а также ограничение на использование памяти не такое суровое // например нативный reqExp уронит node при 3_400 группах, либо при 68_000 обычных регулярках (В этот лимит никогда не попадем, так как максимум активных регулярок порядка 10_000) // Поэтому если нет re2, то лимиты на количество активных регулярок должно быть меньше, для групп сильно меньше if (total < 0.8 * 1024 ** 3) { MAX_COUNT_FOR_GROUP = 200; MAX_COUNT_FOR_REG = 1000; } else if (total < 1.5 * 1024 ** 3) { MAX_COUNT_FOR_GROUP = 800; MAX_COUNT_FOR_REG = 1400; } else if (total < 3 * 1024 ** 3) { MAX_COUNT_FOR_GROUP = 1500; MAX_COUNT_FOR_REG = 3000; } else { MAX_COUNT_FOR_GROUP = 6800; MAX_COUNT_FOR_REG = 7000; } // Если нет re2, то количество активных регулярок для групп, нужно сильно сократить, иначе возможно падение nodejs if (!RegExp_1.__$usedRe2) { MAX_COUNT_FOR_GROUP /= 20; MAX_COUNT_FOR_REG /= 2; } } setMemoryLimit(); /** * Специальное имя команды для обработки неизвестных запросов. * * Если ни одна из зарегистрированных команд не сработала, * и существует команда с именем `FALLBACK_COMMAND`, * её callback будет выполнен как fallback-обработчик. * * @example * ```ts * bot.addCommand(FALLBACK_COMMAND, [], (cmd, ctrl) => { * ctrl.text = 'Извините, я вас не понял. Скажите "помощь" для списка команд.'; * ctrl.buttons.addBtn('Помощь'); * }); * ``` * * @remarks * - Fallback срабатывает только если нет совпадений по слотам. * - Не влияет на стандартные интенты (`welcome`, `help`). * - Можно зарегистрировать только одну fallback-команду (последняя перезапишет предыдущую). * - Можно просто передать "*" */ exports.FALLBACK_COMMAND = '*'; /** * Класс, который берет на себя всю обязанность за регистрацию команд и шагов * @internal Используется только внутри фреймворка */ class CommandReg { #regExpCommandCount = 0; /** * Сгруппированные регулярные выражения. Начинает отрабатывать как только было задано более 250 регулярных выражений */ regexpGroup = new Map(); #noFullGroups = null; /** * Добавленные команды для обработки */ commands = new Map(); #exactMatchMap = new Map(); /** * Добавленные шаги для обработки */ steps = new Map(); /** * Флаг строгого режима работы приложения. * В строгом режиме работы, все ReDOS регулярные выражения не будут добавляться. */ strictMode = false; #commandGroupMode = 'auto'; /** * Кастомизация поиска команд. */ customCommandResolver; logError; logWarn; plugins; constructor(logger, plugins) { this.logWarn = logger.warn; this.logError = logger.error; this.plugins = plugins; } getExactMatchCommand(userCommand) { return this.#exactMatchMap.get(userCommand); } /** * Возвращает кастомный обработчик для обработки регулярных выражений */ getCustomRegExp() { const reg = this.plugins.regExp; if (reg) { return typeof reg === 'function' ? reg() : reg.getData(); } return undefined; } setCommandGroupMode(mode) { this.#commandGroupMode = mode; } /** * Определяет опасная передана регулярка или нет * @param slots */ isDangerRegex(slots) { if ((0, RegExp_1.isRegex)(slots)) { if (!(0, utils_1.isRegexLikelySafe)(slots.source, true)) { this[this.strictMode ? 'logError' : 'logWarn'](`Найдено небезопасное регулярное выражение (ReDOS), проверьте его корректность: ${slots.source}`, {}); if (this.strictMode) { return { status: false, slots: [], }; } else { return { status: true, slots: [slots] }; } } return { status: true, slots: [slots], }; } else { const correctSlots = []; const errors = []; for (const slot of slots) { const isReg = (0, RegExp_1.isRegex)(slot); const slotStr = isReg ? slot.source : slot; if ((0, utils_1.isRegexLikelySafe)(slotStr, isReg)) { correctSlots.push(slot); } else { errors.push(slotStr); } } const status = errors.length === 0; if (!status) { this[this.strictMode ? 'logError' : 'logWarn']?.(`Найдены небезопасные регулярные выражения (ReDOS), проверьте их корректность: ${errors.join(', ')}`, {}); errors.length = 0; } return { status, slots: this.strictMode ? correctSlots : slots }; } } #timeOutReg; #oldFnGroup; #oldGroupName; #getGroupRegExp(groupData, slots, group, useReg = true, isRegUp = true) { group.butchRegexp ??= []; const parts = slots.map((s) => { return `(${typeof s === 'string' ? s : s.source})`; }); const groupIndex = group.butchRegexp.length; // Для уменьшения длины регулярного выражения, а также для исключения случая, // когда имя команды может быть не корректным для имени группы, сами задаем корректное имя с учетом индекса const pat = `(?<_${groupIndex}>${parts?.join('|')})`; group.butchRegexp.push(pat); group.regExpSize += pat.length; const pattern = group.butchRegexp.join('|'); if (useReg) { if (group.name !== this.#oldGroupName && this.#timeOutReg) { this.#oldFnGroup?.(); this.#oldGroupName = group.name; } if (this.#timeOutReg) { clearTimeout(this.#timeOutReg); this.#timeOutReg = undefined; } this.#oldFnGroup = () => { const pattern = group.butchRegexp.join('|'); const regExp = (0, RegExp_1.getRegExp)(pattern, 'ium', this.getCustomRegExp()); if (isRegUp) { // прогреваем регулярку regExp.test('__umbot_testing'); regExp.test(''); } groupData.regExp = regExp; this.#timeOutReg = undefined; this.#oldFnGroup = undefined; }; this.#timeOutReg = setTimeout(this.#oldFnGroup, 35).unref(); return; } else { if (this.#timeOutReg && this.#oldGroupName !== group.name) { this.#oldFnGroup?.(); } clearTimeout(this.#timeOutReg); this.#oldFnGroup = undefined; this.#oldGroupName = undefined; this.#timeOutReg = undefined; } groupData.regExp = pattern; } #addRegexpInGroup(commandName, slots, isRegexp) { // Если количество команд до 300, то нет необходимости в объединении регулярок, так как это не даст сильного преимущества if (this.#commandGroupMode === 'no-group' || (this.#commandGroupMode === 'auto' && this.#regExpCommandCount < 300)) { return commandName; } if (isRegexp) { if (!(0, utils_1.isRegexLikelySafe)(slots.join('|'), false)) { return commandName; } if (this.#noFullGroups) { let groupName = this.#noFullGroups.name; let groupData = this.regexpGroup.get(groupName) || { commands: [], regExp: null }; if (this.#noFullGroups.butchRegexp.length === 1 && this.#noFullGroups.name !== commandName) { const command = this.commands.get(this.#noFullGroups.name); if (command) { command.regExp = undefined; command.isRegExpString = false; this.commands.set(this.#noFullGroups.name, command); } } // В среднем 9 символов зарезервировано под стандартный шаблон для группы регулярки. Даем примерно 60 регулярок по 5 символов if (this.#noFullGroups.regLength >= 60 || (this.#noFullGroups.regExpSize || 0) > 850) { groupData = { commands: [], regExp: null }; groupName = commandName; this.#noFullGroups = { name: commandName, regLength: 0, butchRegexp: [], regExpSize: 0, }; } groupData.commands.push(commandName); this.#getGroupRegExp(groupData, slots, this.#noFullGroups, this.regexpGroup.size < MAX_COUNT_FOR_GROUP); this.regexpGroup.set(groupName, groupData); this.#noFullGroups.regLength += slots.length; return groupName; } else { const butchRegexp = []; const parts = slots.map((s) => { return `(${typeof s === 'string' ? s : s.source})`; }); butchRegexp.push(`(?<${commandName}>${parts?.join('|')})`); const regExp = (0, RegExp_1.getRegExp)(`${butchRegexp.join('|')}`, 'ium', this.getCustomRegExp()); this.#noFullGroups = { name: commandName, regLength: slots.length, butchRegexp, regExpSize: regExp.source.length, }; this.regexpGroup.set(commandName, { commands: [commandName], regExp, }); return commandName; } } else { if (this.#noFullGroups) { if (this.regexpGroup.has(this.#noFullGroups.name) && (this.regexpGroup.get(this.#noFullGroups.name).commands .length || 0) < 2) { this.regexpGroup.delete(this.#noFullGroups.name); } this.#noFullGroups = null; } return null; } } #removeRegexpInGroup(commandName) { const getReg = (groupData, newCommandName, newCommands, group, useReg) => { newCommands.forEach((cName) => { const command = this.commands.get(cName); if (command) { command.__$groupName = newCommandName; this.commands.set(cName, command); this.#getGroupRegExp(groupData, command.slots, group, useReg, false); } }); }; if (this.regexpGroup.has(commandName)) { const group = this.regexpGroup.get(commandName); this.regexpGroup.delete(commandName); if (group?.commands?.length) { const newCommands = group?.commands.filter((gCommand) => { return gCommand !== commandName; }); const newCommandName = newCommands[0]; const nGroup = { name: newCommandName, regLength: 0, butchRegexp: [], regExpSize: 0, }; const groupData = { commands: newCommands, regExp: null, }; getReg(groupData, newCommandName, newCommands, nGroup, typeof group.regExp !== 'string'); this.regexpGroup.set(newCommandName, groupData); } } else if (this.commands.has(commandName)) { const command = this.commands.get(commandName); if (command?.__$groupName && this.regexpGroup.has(command?.__$groupName)) { const group = this.regexpGroup.get(command.__$groupName); if (group) { const newCommands = group?.commands.filter((gCommand) => { return gCommand !== commandName; }); const nGroup = { name: commandName, regLength: 0, butchRegexp: [], regExpSize: 0, }; const groupData = { commands: newCommands, regExp: null, }; getReg(groupData, commandName, newCommands, nGroup, typeof group.regExp !== 'string'); this.regexpGroup.set(command.__$groupName, groupData); } } } } /** * Добавляет команду для обработки пользовательских запросов * * ⚙️ Оптимизация: при регистрации более 300 команд с регулярными выражениями * фреймворк автоматически объединяет их в группы для повышения производительности. * * @param {string} commandName - Уникальный идентификатор команды * @param {TSlots} slots - Триггеры для активации команды * - Если элемент — строка → ищется как подстрока (`text.includes(...)`). * - Если элемент — RegExp → проверяется как регулярное выражение (`.test(text)`). * - Параметр `isPattern` учитывается **только если в `slots` нет RegExp**. * - Если в slots присутствует хотя бы один RegExp, параметр isPattern игнорируется. Каждый элемент обрабатывается по своему типу: * - string → как литерал (поиск подстроки), * - RegExp → как регулярное выражение * @param {ICommandParam['cb']} cb - Функция-обработчик команды * @param {boolean} isPattern - Использовать регулярные выражения (по умолчанию false) * * @example * Простая команда со словами: * ```ts * commandReg.addCommand( * 'greeting', * ['привет', 'здравствуй'], * (cmd, ctrl) => { * if (ctrl) ctrl.text = 'Здравствуйте!'; * } * ); * ``` * * @example * Команда с регулярными выражениями: * ```ts * // Обработка чисел от 1 до 999 * commandReg.addCommand( * 'number', * ['\\b([1-9]|[1-9][0-9]|[1-9][0-9][0-9])\\b'], * (cmd, ctrl) => { * if (ctrl) ctrl.text = `Вы ввели число: ${cmd}`; * }, * true // включаем поддержку регулярных выражений * ); * ``` * * @example * Команда с доступом к состоянию: * ```ts * commandReg.addCommand( * 'stats', * ['статистика'], * async (cmd, ctrl) => { * if (ctrl) { * // Доступ к пользовательским данным * const visits = ctrl.userData?.visits || 0; * ctrl.text = `Вы использовали приложение ${visits} раз`; * * // Доступ к кнопкам и другим UI элементам * ctrl.buttons * .addBtn('Сбросить статистику') * .addBtn('Закрыть'); * } * } * ); * ``` * * @remarks * Поиск команд оптимизирован: * 1. Сначала проверяется точное совпадение * 2. Если точного совпадения нет — выполняется последовательный перебор * * При регистрации более 300 команд с регулярными выражениями * фреймворк автоматически объединяет их в группы для повышения производительности. * * При isPattern=true используются регулярные выражения JavaScript * В callback доступен весь функционал BotController * Можно использовать async функции в callback */ addCommand(commandName, slots, cb, isPattern = false) { if (this.commands.get(commandName)) { this.logWarn(`Команда с названием "${commandName === exports.FALLBACK_COMMAND ? '* (fallback command)' : commandName}" уже создавалась ранее. Ранее созданная команда будет перезаписана. Рекомендуется проверить корректность регистрации команды`); } if (commandName === exports.FALLBACK_COMMAND) { this.commands.set(commandName, { slots: undefined, isPattern: false, cb: cb, regExp: undefined, isRegExpString: false, __$groupName: commandName, }); return; } const size = this.commands.size; if (LIMIT_COMMANDS.includes(size)) { this.logWarn(`Задано ${this.commands.size} команд, скорее всего команды задаются через цикл, который возможно отработал некорректно. Проверьте корректность работы приложения, а также корректность добавленных команд.`); } let correctSlots = this.strictMode ? [] : slots; let regExp; let groupName; if (isPattern) { correctSlots = this.isDangerRegex(slots).slots; if (correctSlots.length) { groupName = this.#addRegexpInGroup(commandName, correctSlots, true); if (groupName === commandName) { this.#regExpCommandCount++; if (this.#regExpCommandCount < MAX_COUNT_FOR_REG) { regExp = (0, RegExp_1.getRegExp)(correctSlots, 'ium', this.getCustomRegExp()); regExp.test('__umbot_testing'); regExp.test(''); } } } } else { this.#addRegexpInGroup(commandName, correctSlots, false); for (let i = 0; i < slots.length; i++) { const slot = slots[i]; if ((0, RegExp_1.isRegex)(slot)) { const res = this.isDangerRegex(slot); if (res.status && this.strictMode) { correctSlots.push(slot); } } else { if (this.strictMode) { correctSlots.push(slot); } const tCommandName = this.#exactMatchMap.get(slot); if (!tCommandName) { this.#exactMatchMap.set(slot, commandName); } } } } if (correctSlots.length) { this.commands.set(commandName, { slots: correctSlots, isPattern, cb: cb, regExp, isRegExpString: typeof regExp !== 'string', __$groupName: groupName, }); } } /** * Удаляет команду * @param commandName - Имя команды */ removeCommand(commandName) { if (commandName === exports.FALLBACK_COMMAND) { this.commands.delete(commandName); return; } if (this.commands.has(commandName)) { const command = this.commands.get(commandName); if (command?.isPattern && command.regExp) { this.#regExpCommandCount--; if (this.#regExpCommandCount < 0) { this.#regExpCommandCount = 0; } } command?.slots?.forEach((slot) => { if (!(0, RegExp_1.isRegex)(slot)) { this.#exactMatchMap.delete(slot); } }); this.commands.delete(commandName); } this.#removeRegexpInGroup(commandName); } /** * Удаляет все команды */ clearCommands() { this.commands.clear(); this.#noFullGroups = null; this.#regExpCommandCount = 0; this.regexpGroup.clear(); this.#exactMatchMap.clear(); this.#oldGroupName = undefined; this.#oldFnGroup = undefined; clearTimeout(this.#timeOutReg); this.#timeOutReg = undefined; } /** * Регистрирует обработчик для именованного шага диалога. * * Шаг — это часть **многошагового сценария** (например: "регистрация", "оформление заказа"). * После вызова `ctx.thisIntentName = 'myStep'` в команде или другом шаге, * следующее сообщение пользователя будет обработано этим обработчиком. * * > 💡 Обработчик получает полный `BotController`, как и в командах: * > доступны `this.text`, `this.userData`, `this.buttons`, `this.setStep()` и т.д. * * @param stepName — Уникальное имя шага (например, `'enter_email'`). * @param handler — Функция, вызываемая при получении сообщения в этом шаге. * @returns Текущий экземпляр `Bot` (для цепочки вызовов). * * @example * ```ts * bot.addStep('confirm_age', (ctx) => { * if (ctx.userCommand === 'да') { * ctx.text = 'Отлично! Добро пожаловать.'; * ctx.clearStep(); // завершаем сценарий * } else { * ctx.text = 'Извините, вход запрещён.'; * ctx.setStep('goodbye'); // переходим к другому шагу * } * }); * ``` */ addStep(stepName, handler) { this.steps.set(stepName, { stepName: stepName, cb: handler, }); return this; } /** * Удаляет зарегистрированный шаг по имени. * * После удаления шаг больше не будет обрабатываться, даже если активен у пользователя. * (Рекомендуется завершать активные сценарии через `ctx.clearStep()` перед удалением.) * * @param stepName — Имя шага для удаления. * @returns Текущий экземпляр `Bot`. */ removeStep(stepName) { this.steps.delete(stepName); return this; } /** * Удаляет **все** зарегистрированные шаги. * * > ⚠️ Это **глобальная операция**: все сценарии станут недоступны. * > Используйте с осторожностью (например, при перезагрузке логики приложения). * * @returns Текущий экземпляр `Bot`. */ clearSteps() { this.steps.clear(); return this; } } exports.CommandReg = CommandReg;