UNPKG

umbot

Version:

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

527 lines (526 loc) 20.8 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 __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;