umbot
Version:
Мультиплатформенный фреймворк для создания голосовых навыков и чат-ботов с единой бизнес-логикой. Встроенная поддержка ВКонтакте, Telegram, Viber, MAX, Яндекс Алисы, Маруси и Сбера SmartApp. Архитектура на адаптерах позволяет подключать любые другие платф
589 lines (588 loc) • 27.1 kB
JavaScript
"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;