umbot
Version:
Мультиплатформенный фреймворк для создания голосовых навыков и чат-ботов с единой бизнес-логикой. Встроенная поддержка ВКонтакте, Telegram, Viber, MAX, Яндекс Алисы, Маруси и Сбера SmartApp. Архитектура на адаптерах позволяет подключать любые другие платф
527 lines (526 loc) • 20.8 kB
JavaScript
;
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 __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.AppContext = exports.HELP_INTENT_NAME = exports.WELCOME_INTENT_NAME = exports.T_AUTO = void 0;
const CommandReg_1 = require("./utils/CommandReg");
const EnvConfig_1 = require("../utils/EnvConfig");
const utils_1 = require("../utils");
const process = __importStar(require("node:process"));
const node_path_1 = require("node:path");
/**
* Тип платформы: Автоопределение
*/
exports.T_AUTO = 'auto';
/**
* Идентификатор интента приветствия
*/
exports.WELCOME_INTENT_NAME = 'welcome';
/**
* Идентификатор интента помощи
*/
exports.HELP_INTENT_NAME = 'help';
const regBot = /bot\d+:[A-Za-z0-9_-]{35,}/g;
const regVk = /vk1a[a-z0-9]{79}/g;
const regVk2 = /("access_token"\s*:|client_secret|vk_confirmation_token|sber_token|oauth|api_key|private_key)\s*:\s*"([^"]{8,})"/g;
const regToken = /"[A-Za-z0-9+/=]{30,256}"/g;
const regToken2 = /\b[A-Za-z0-9]{64,256}\b/g;
const PATTERNS = [
{ regex: regBot, replacement: 'bot***' },
{ regex: regVk, replacement: 'vk1a***' },
{
regex: regVk2,
replacement: '$1:"***"',
},
{ regex: regToken, replacement: '"***"' },
{ regex: regToken2, replacement: '***' },
];
/**
* Внутренний класс для хранения состояния и конфигурации приложения.
* Используется внутри Bot для хранения состояния и конфигурации
*
* @remarks
* Разработчикам обычно НЕ нужно создавать экземпляры этого класса напрямую.
* Вместо этого используйте методы класса {@link Bot}:
* - `bot.getAppContext()` - получить доступ к контексту
* - `bot.setAppConfig()` - настроить конфигурацию
* - `bot.setPlatformParams()` - настроить параметры платформ
*
* Этот класс содержит:
* - Конфигурацию приложения (IAppConfig)
* - Параметры платформ (IAppParam)
* - Регистрацию команд и интентов
* - Логирование и метрики
* - Подключение к БД
*
* @example
* ```ts
* // НЕ ТАК:
* const appContext = new AppContext();
*
* // ТАК (правильно):
* const bot = new Bot();
* const appContext = bot.getAppContext(); // если нужен прямой доступ
* ```
*/
class AppContext {
/**
* Список подключенных платформ
*/
platforms = {};
/**
* Список подключенных плагинов
*/
plugins = {};
/**
* Информация по подключению к базе данных.
*/
database = {};
#errWarnData = {
errors: [],
warnings: [],
timeout: null,
};
#logErrorBind = this.logError.bind(this);
/**
* Все зарегистрированные команды и шаги
*/
command = new CommandReg_1.CommandReg({
warn: this.logWarn.bind(this),
error: this.#logErrorBind,
}, this.plugins);
/**
* Получение всех зарегистрированных команд
*/
get commands() {
return this.command.commands;
}
/**
* Получение всех зарегистрированных шагов
*/
get steps() {
return this.command.steps;
}
/**
* Получение всех зарегистрированных команд, которые распределены по группам.
* В группу добавляются только команды с регулярными выражениями.
* Группы используются для оптимизации поиска нужной команды
*/
get regexpGroup() {
return this.command.regexpGroup;
}
/**
* Переменные окружения
*/
#envVars;
/**
* Кастомный logger приложения
*/
#logger = null;
/**
* Конфигурация приложения
*/
appConfig = {
error_log: (0, node_path_1.join)(__dirname, '..', '..', 'logs'),
json: (0, node_path_1.join)(__dirname, '..', '..', 'json'),
db: { host: '', user: '', pass: '', database: '' },
isLocalStorage: false,
tokens: {},
env: '',
};
/**
* Параметры приложения
*/
platformParams = {
isAuthUser: false,
welcome_text: 'Текст приветствия',
help_text: 'Текст помощи',
empty_text: 'Извините, но я вас не понимаю',
intents: [
{ name: exports.WELCOME_INTENT_NAME, slots: ['привет', 'здравст'] },
{ name: exports.HELP_INTENT_NAME, slots: ['помощь', 'что ты умеешь'] },
],
utm_text: null,
};
/**
* Кастомный HTTP-клиент для выполнения всех исходящих запросов фреймворка.
* По умолчанию используется глобальный `fetch`.
* Вы можете заменить его на любой совместимый клиент (например, axios, undici, got),
* реализующий интерфейс:
* ```ts
* (input: RequestInfo, init?: RequestInit) => Promise<Response>
* ```
* * Это позволяет:
* - добавлять retry-логику, таймауты, circuit breaker;
* - внедрять tracing, метрики или логирование всех запросов;
* - мокать сетевые вызовы в тестах;
* - использовать альтернативные HTTP-библиотеки.
* @example
* ```ts
* const bot = new Bot();
* const ctx = bot.getAppContext();
* ctx.httpClient = async (url, options) => {
* // добавляем таймаут 5 сек
* const controller = new AbortController();
* const id = setTimeout(() => controller.abort(), 5000);
* try {
* const res = await fetch(url, { ...options, signal: controller.signal });
* clearTimeout(id);
* return res;
* } catch (e) {
* clearTimeout(id);
* throw e;
* }
* };
*```
*/
httpClient = global.fetch;
/**
* Определяет режим работы приложения
*/
appMode = 'dev';
/**
* Закрывает все подключения, для корректного завершения работы приложения
*/
async close() {
this.#saveErrorData();
if (this.database.adapter && this.database.isSendConnect) {
return this.database.adapter.destroy();
}
}
/**
* Установка всех токенов из переменных окружения или параметров
*/
#setTokens() {
const envVars = this.#getEnvVars();
if (envVars) {
// Не самое хорошее решение, но возможно этот вариант кому-то удобен
if (envVars.VIBER_TOKEN) {
this.appConfig.tokens.viber ??= {};
this.appConfig.tokens.viber.token = envVars.VIBER_TOKEN;
}
if (envVars.TELEGRAM_TOKEN) {
this.appConfig.tokens.telegram ??= {};
this.appConfig.tokens.telegram.token = envVars.TELEGRAM_TOKEN;
}
if (envVars.VK_TOKEN) {
this.appConfig.tokens.vk ??= {};
this.appConfig.tokens.vk.token = envVars.VK_TOKEN;
this.appConfig.tokens.vk.confirmation_token =
envVars.VK_CONFIRMATION_TOKEN || this.appConfig.tokens.vk.confirmation_token;
}
if (envVars.MAX_TOKEN) {
this.appConfig.tokens.max_app ??= {};
this.appConfig.tokens.max_app.token = envVars.MAX_TOKEN;
}
if (envVars.MARUSIA_TOKEN) {
this.appConfig.tokens.marusia ??= {};
this.appConfig.tokens.marusia.token = envVars.MARUSIA_TOKEN;
}
if (envVars.YANDEX_TOKEN) {
this.appConfig.tokens.alisa ??= {};
this.appConfig.tokens.alisa.token = envVars.YANDEX_TOKEN;
}
}
}
/**
* Возвращает объект с настройками окружения
* @param {string|undefined} envPath - Путь к файлу окружения
*/
#getEnvVars(envPath = this.appConfig?.env) {
const setEnvFn = (errorMsg) => {
let correctEnvValue = {};
// Используем доступ к env, чтобы получить токены для Viber, Telegram и других сервисов. Это необходимая для работы с api и базой данных.
// Используется только в случае если явно хотят работать с env файлами.
if (process.env) {
const {
// Получаем токен для viber
VIBER_TOKEN,
// Получаем токен для talegram
TELEGRAM_TOKEN,
// Получаем токен для vk
VK_TOKEN,
// Получаем токен для max
MAX_TOKEN,
// Получаем токен для подтверждения vk
VK_CONFIRMATION_TOKEN,
// Получаем токен для маруси
MARUSIA_TOKEN,
// Получаем токен для работы с api яндекса
YANDEX_TOKEN,
// Получаем хост для подключения к базе
DB_HOST,
// Получаем имя пользователя для подключения к базе
DB_USER,
// Получаем пароль пользователя для подключения к базе
DB_PASSWORD,
// Получаем имя базы данных
DB_NAME, } = process.env;
correctEnvValue = {
VIBER_TOKEN,
TELEGRAM_TOKEN,
VK_TOKEN,
MAX_TOKEN,
VK_CONFIRMATION_TOKEN,
MARUSIA_TOKEN,
YANDEX_TOKEN,
DB_HOST,
DB_USER,
DB_PASSWORD,
DB_NAME,
};
}
let isError = true;
Object.values(correctEnvValue).forEach((correctEnvValue) => {
if (correctEnvValue) {
isError = false;
}
});
if (isError) {
this.logError('AppContext: ' + errorMsg);
}
else {
this.#envVars = correctEnvValue;
}
};
if (envPath === 'local') {
setEnvFn('Не удалось получить данные из process.env');
// Возвращаем результаты сразу, чтобы не возникло ситуации, когда пытается прочитать файл local
return this.#envVars;
}
if (this.#envVars) {
return this.#envVars;
}
if (envPath) {
const res = (0, EnvConfig_1.loadEnvFile)(envPath);
if (res.status) {
this.#envVars = res.data;
}
else {
setEnvFn(res.error + '. Также не удалось получить данные из process.env');
}
}
return this.#envVars;
}
/**
* Устанавливает конфигурацию приложения
* @param {IAppConfig} config - Пользовательская конфигурация
*/
setAppConfig(config) {
this.appConfig = { ...this.appConfig, ...config };
if (config.env) {
const envVars = this.#getEnvVars(config.env);
if (envVars) {
// Пишем в конфиг для подключения к БД, только если есть настройки для подключения
if (this.appConfig.db || envVars.DB_HOST || envVars.DB_NAME) {
this.appConfig.db = {
...this.appConfig.db,
host: envVars.DB_HOST || this.appConfig.db?.host,
user: envVars.DB_USER || this.appConfig.db?.user,
pass: envVars.DB_PASSWORD || this.appConfig.db?.pass,
database: envVars.DB_NAME || this.appConfig.db?.database,
};
}
this.#setTokens();
}
}
}
/**
* Устанавливает параметры приложения
* @param {IAppParam} params - Пользовательские параметры
*/
setPlatformParams(params) {
this.platformParams = { ...this.platformParams, ...params };
this.platformParams.intents =
this.platformParams.intents?.filter((intent) => {
if (intent.is_pattern) {
const res = this.command.isDangerRegex(intent.slots);
if (res.slots.length) {
if (res.slots.length !== intent.slots.length) {
intent.slots = res.slots;
}
return true;
}
return false;
}
return true;
}) || [];
this.#setTokens();
}
/**
* Позволяет установить свою реализацию для логирования
* @param logger
*/
setLogger(logger) {
this.#logger = logger;
}
/**
* Логирование информации
* @param args
*/
log(...args) {
if (this.#logger?.log) {
this.#logger.log(...args);
}
else {
console.log(...args);
}
}
/**
* Логирование ошибки
* @param str
* @param meta
*/
logError(str, meta) {
if (this.#logger?.error) {
this.#logger.error(this.appMode.includes('strict') ? this.#maskSecrets(str) : str, meta);
}
else {
this.#errWarnLog(`${str}\n${JSON.stringify({ ...meta, trace: new Error().stack }, null, '\t')}`, true);
}
}
/**
* Возвращает флаг, который говорит о том необходимо собирать метрики или нет
*/
get usedMetric() {
return !!this.#logger?.metric;
}
/**
* Логирование метрики
* @param name - имя метрики
* @param value - значение
* @param label - Дополнительные метаданные
*/
logMetric(name, value, label) {
if (this.#logger?.metric) {
this.#logger.metric(name, value, label);
}
}
#saveErrorData() {
if (this.#errWarnData.timeout) {
clearTimeout(this.#errWarnData.timeout);
}
if (this.#errWarnData.warnings.length) {
this.#saveLog('warn.log', this.#errWarnData.warnings.join('\n'), false).catch(() => {
// ignore error
});
}
if (this.#errWarnData.errors.length) {
this.#saveLog('error.log', this.#errWarnData.errors.join('\n'), false).catch(() => {
// ignore error
});
}
this.#errWarnData.errors = [];
this.#errWarnData.warnings = [];
this.#errWarnData.timeout = null;
}
#errWarnLog(msg, isError) {
if (isError) {
this.#errWarnData.errors.push(`[${new Date().toISOString()}]: ${msg}`);
}
else {
this.#errWarnData.warnings.push(`[${new Date().toISOString()}]: ${msg}`);
}
if (!this.#errWarnData.timeout) {
this.#errWarnData.timeout = setTimeout(() => {
this.#errWarnData.timeout = null;
this.#saveErrorData();
}, 200).unref();
}
}
/**
* Логирование предупреждения
* @param str
* @param meta
*/
logWarn(str, meta) {
if (this.#logger?.warn) {
this.#logger.warn(this.appMode.includes('strict') ? this.#maskSecrets(str) : str, {
...meta,
});
}
else {
if (this.appMode === 'dev') {
console.warn(this.appMode.includes('strict') ? this.#maskSecrets(str) : str, meta);
}
this.#errWarnLog(`${str}\n${JSON.stringify({ ...meta }, null, '\t')}`, false);
}
}
/**
* Сохраняет данные в JSON файл
* @param fileName - Имя файла
* @param data - Данные для сохранения
* @returns true в случае успешного сохранения
*/
saveFileData(fileName, data) {
const dir = {
path: this.appConfig.json || (0, node_path_1.join)(__dirname, '..', '..', 'json'),
fileName: fileName,
};
return (0, utils_1.saveData)(dir, JSON.stringify(data), undefined, this.#logErrorBind);
}
/**
* Скрывает секретные данные в тексте
* @param text
*/
#maskSecrets(text) {
if (!text || this.#logger?.maskSecrets === false) {
return text;
}
let result = text;
for (const { regex, replacement } of PATTERNS) {
result = result.replace(regex, replacement);
}
return result;
}
/**
* Сохраняет лог ошибки
* @param {string} fileName - Имя файла лога
* @param {string} errorText - Текст ошибки
* @param {boolean} usedDate - Флаг, говорящий о том, нужно ли добавлять время или нет.
* @returns {boolean} true в случае успешного сохранения
*/
#saveLog(fileName, errorText = '', usedDate = true) {
const msg = `${usedDate ? `[${new Date().toISOString()}]: ` : ''}${errorText}\n`;
const dir = {
path: this.appConfig.error_log || (0, node_path_1.join)(__dirname, '..', '..', 'json'),
fileName,
};
if (this.appMode === 'dev') {
console.error(msg);
}
return (0, utils_1.saveData)(dir, this.#maskSecrets(msg), 'a', this.#logErrorBind);
}
}
exports.AppContext = AppContext;