UNPKG

vvlad1973-telegram-framework

Version:
723 lines (629 loc) 21.8 kB
'use strict'; /** * Scenario processor with user tracing support * * This is a patched version of vvlad1973-telegram-framework/src/classes/scenario.js * with added user tracing capabilities for debugging specific users. * * @module Scenario */ import { safe as jsonc } from 'jsonc'; import { getCallerName, isTextMatch, clone } from 'vvlad1973-utils'; import { ValidationError } from 'vvlad1973-error-definitions'; import SimpleLogger from 'vvlad1973-simple-logger'; export class Scenario { actions = {}; context = {}; script = []; sender; filler; logger; stackCounter = 0; /** * User tracing service - injected from application * @type {Object|null} */ userTracing = null; /** * Per-request tracing context * @type {Map<string|number, Object>} */ #traceContext = new Map(); constructor(logger) { this.actions['setDelay'] = this.setDelay; this.actions['isMatch'] = this.isMatch; this.actions['doSwitch'] = this.doSwitch; this.actions['doDialog'] = this.doDialog; this.logger = logger ?? new SimpleLogger(); } #logEntry(functionName) { functionName = functionName ?? getCallerName(); this.logger.trace(`Function ${functionName}() execution...`); } #logExit(functionName) { functionName = functionName ?? getCallerName(); this.logger.trace(`Function ${functionName}() complete`); } /** * Trace event if tracing enabled for user * @param {string|number} userId - User ID * @param {string} event - Event name * @param {Object} data - Event data * @private */ #trace(userId, event, data = {}) { if (this.userTracing?.isTracing(userId)) { this.userTracing.trace(userId, event, data); } } /** * Format value for trace output (prevent huge objects in logs) * @param {*} value - Value to format * @returns {*} Formatted value * @private */ #formatValue(value) { if (value === undefined) return { _type: 'undefined' }; if (value === null) return null; if (typeof value === 'boolean' || typeof value === 'number') return value; if (typeof value === 'string') { return value.length > 200 ? { _type: 'string', _len: value.length, _preview: value.substring(0, 200) } : value; } if (Array.isArray(value)) { return value.length <= 5 ? value.map((v) => this.#formatValue(v)) : { _type: 'array', _len: value.length }; } if (typeof value === 'object') { const keys = Object.keys(value); if (keys.length <= 10) { const result = {}; for (const key of keys) result[key] = this.#formatValue(value[key]); return result; } return { _type: 'object', _keys: keys }; } return { _type: typeof value }; } /** * Count actions in data * @param {*} actions - Actions data * @returns {number} Count * @private */ #countActions(actions) { if (!actions) return 0; return Array.isArray(actions) ? actions.length : 1; } loadDialogs(dialogs) { return new Promise((resolve, reject) => { let self = this, scenario = [], keys = []; function loadItem(item) { let fileName; if (typeof item === 'string') fileName = item; else if (typeof item === 'object') if (item.file) fileName = item.file; else if (item.collection) collection = item.collection; if (fileName) { try { let [error, bulk] = jsonc.readSync(fileName, { stripComments: true, }); if (error) throw new ValidationError( `Corrupt dialog file ${fileName}: ${error.message}` ); // bulk = require(path.resolve(process.cwd(), fileName)); if (Array.isArray(bulk)) { for (let [index, dialog] of bulk.entries()) { if (dialog._id) { if (keys.includes(dialog._id)) self.logger.warn( `Dialog ID ${dialog._id} is duplicated and was skipped` ); else { self.logger.trace(`Dialog ID ${dialog._id} was loaded`); keys.push(dialog._id); scenario.push(dialog); } } else throw new ValidationError( `Unknown dialog ID for item ${index}` ); } } else throw new ValidationError('Wrong format of dialogs file'); } catch (error) { self.logger.error(error.message); } } } if (Array.isArray(dialogs)) { for (let item of dialogs) loadItem(item); } else loadItem(dialogs); self.script = scenario; return resolve(); }); } doAction(data, request, user, context, self) { self = self ?? this; self.stackCounter++; this.#logEntry(`Scenario.doAction[${self.stackCounter}]`); let actionsData = clone(data); const userId = user?.id; const ctx = this.#traceContext.get(userId); const currentDialogId = ctx?.dialogId; return new Promise(async (resolve, reject) => { if (typeof actionsData === 'object') { if (Array.isArray(actionsData)) { for (const item of actionsData) { try { await self.doAction(item, request, user, context, self); } catch (error) { return reject(error); } } } else if (typeof self.actions[actionsData.action] === 'function') { const actionName = actionsData.action; const actionStart = Date.now(); if (ctx) ctx.actionsCount++; // TRACE: action start this.#trace(userId, 'action_start', { action: actionName, options: this.#formatValue(actionsData.options), stack: self.stackCounter, dialogId: currentDialogId, }); self.logger.info( `[userId=${user?.id}] Action ${actionsData.action} execution...` ); self.logger.debug( actionsData.options, `[userId=${user?.id}] Action options:` ); try { let result; try { result = await self.actions[actionsData.action]( actionsData, request, user, context, self ); } catch (error) { if (actionsData.result?.false) { result = false; // TRACE: error caught, fallback to false branch this.#trace(userId, 'action_error_caught', { action: actionName, error: error.message, fallbackBranch: 'false', }); self.logger.error(error); } else throw error; } // TRACE: action result this.#trace(userId, 'action_result', { action: actionName, result: this.#formatValue(result), duration: Date.now() - actionStart, stack: self.stackCounter, }); self.logger.info( `[userId=${user?.id}] Action ${actionsData.action} complete` ); self.logger.debug(result, `[userId=${user?.id}] Action result:`); if (actionsData.result?.true && result) { // TRACE: branch true this.#trace(userId, 'action_branch', { action: actionName, branch: 'true', condition: this.#formatValue(result), nextActionsCount: this.#countActions(actionsData.result.true), }); self.logger.info( `[userId=${user?.id}] Result True action is defined. Action will execute...` ); await self.doAction( actionsData.result.true, request, user, context, self ); } else if ( actionsData.result?.false && typeof result !== 'undefined' && !result ) { // TRACE: branch false this.#trace(userId, 'action_branch', { action: actionName, branch: 'false', condition: this.#formatValue(result), nextActionsCount: this.#countActions(actionsData.result.false), }); self.logger.info( `[userId=${user?.id}] Result False action is defined. Action will execute...` ); await self.doAction( actionsData.result.false, request, user, context, self ); } } catch (error) { // TRACE: action error this.#trace(userId, 'action_error', { action: actionName, error: error.message, stack: error.stack, duration: Date.now() - actionStart, stackLevel: self.stackCounter, }); return reject(error); } } else if (actionsData.action) { // TRACE: action not defined this.#trace(userId, 'action_not_defined', { action: actionsData.action, stack: self.stackCounter, }); self.logger.warn( `[userId=${user.id}] Action ${actionsData.action} not defined` ); } else self.logger.error( actionsData, `[userId=${user.id}] Unknown format of data for doAction()` ); this.#logExit(`Scenario.doAction[${self.stackCounter}]`); self.stackCounter--; return resolve(); } else self.logger.error( actionsData, `[userId=${user.id}] Unknown format of data for doAction()` ); }); } async setDelay(data, request, user, context, self) { let defaultDelay = context?.defaults?.delay, delay = data?.options?.delay, chatAction = data?.options?.chat_action, intervalId; if ( chatAction && user?.id && typeof context.telegramBot.sendChatAction === 'function' ) { await context.telegramBot.sendChatAction(user.id, chatAction); intervalId = setInterval( async () => await context.telegramBot.sendChatAction(user.id, chatAction), 3000 ); } delay = 1000 * (delay ?? (defaultDelay instanceof Promise ? await defaultDelay : defaultDelay) ?? 3); await new Promise((resolve, reject) => { setTimeout(async () => { if (intervalId) clearInterval(intervalId); return resolve(); }, delay); }); } async isMatch(data, request = {}, user = {}, context = {}, self = {}) { let { type, contents, params, chat, object } = request, options = data?.options, value = options?.value ?? contents, result = isTextMatch( value, options?.pattern, options?.is_regexp ?? options?.isRegExp, options?.flags ); return result; } async doSwitch(data = {}, request = {}, user = {}, context = {}, self = {}) { const userId = user?.id; let { type, contents, params, chat, object } = request, options = data?.options, cases = data?.cases ?? options?.cases, defaultCase = data?.default ?? options?.default, isCaseFound = false, value = options?.value ?? contents; self.logger.info(`[userId=${userId}] Searching case...`); // TRACE: switch start this.#trace(userId, 'switch_start', { value: this.#formatValue(value), casesCount: Array.isArray(cases) ? cases.length : 0, hasDefault: !!defaultCase, }); if (Array.isArray(cases)) for (let i = 0; i < cases.length; i++) { const item = cases[i]; if ( isTextMatch( value, item.case, options?.is_regexp ?? options?.isRegExp, options?.flags ) ) { isCaseFound = true; // TRACE: case matched this.#trace(userId, 'switch_case_matched', { value: this.#formatValue(value), matchedCase: item.case, caseIndex: i, totalCases: cases.length, actionsCount: this.#countActions(item.actions), }); self.logger.debug(`[userId=${userId}] Case found:\n${item.case}`); await self.doAction(item.actions, request, user, context, self); } } if (!isCaseFound) { if (defaultCase) { // TRACE: default case this.#trace(userId, 'switch_default', { value: this.#formatValue(value), testedCases: cases?.map((c) => c.case) || [], actionsCount: this.#countActions(defaultCase), }); self.logger.debug( `[userId=${userId}] Case not found. Default actions will execute...` ); await self.doAction(defaultCase, request, user, context, self); } else { // TRACE: no match this.#trace(userId, 'switch_no_match', { value: this.#formatValue(value), testedCases: cases?.map((c) => c.case) || [], }); self.logger.debug( `[userId=${userId}] Neither case nor default actions not found` ); } } } async doDialog(data = {}, request = {}, user = {}, context = {}, self = {}) { const userId = user?.id; let isDialogFound = false, dialogId = data?.options.dialog; const ctx = this.#traceContext.get(userId); const fromDialogId = ctx?.dialogId; if (dialogId) { self.logger.info( `[userId=${userId}] Searching dialog id=[${dialogId}]...` ); for (const item of self.script) if (item._id === dialogId) { isDialogFound = true; if (ctx) ctx.dialogId = dialogId; // TRACE: do dialog this.#trace(userId, 'do_dialog', { fromDialog: fromDialogId, toDialog: dialogId, found: true, actionsCount: this.#countActions(item.actions), }); self.logger.info( `[userId=${userId}] Dialog id=[${dialogId}] was found` ); await self.doAction(item.actions, request, user, context, self); // TRACE: return from dialog this.#trace(userId, 'do_dialog_return', { fromDialog: dialogId, toDialog: fromDialogId, }); if (ctx) ctx.dialogId = fromDialogId; break; } if (!isDialogFound) { // TRACE: dialog not found this.#trace(userId, 'do_dialog', { fromDialog: fromDialogId, toDialog: dialogId, found: false, }); self.logger.warn( `[userId=${userId}] Dialog id=[${dialogId}] not found` ); } } } async process(request, user, context) { this.#logEntry(); // Если user — строка, преобразуем в объект с id if (typeof user === 'string') user = { id: user }; const resolvedUser = { id: user.id, channel: await resolveValue(user.channel), role: await resolveValue(user.role), state: await resolveValue(user.state), substate: await resolveValue(user.substate), }; const userId = resolvedUser.id; const startTime = Date.now(); // Initialize trace context this.#traceContext.set(userId, { startTime, dialogId: null, actionsCount: 0, dialogsProcessed: 0, }); // TRACE: request received this.#trace(userId, 'request_received', { request: { type: request.type, contents: this.#formatValue(request.contents), command: request.command, params: request.params, chat_type: request.chat_type, chat: request.chat, route: request.route, }, user: resolvedUser, }); const { chat, type, contents, params, chat_type, object, route, command } = request; let isDialogFound = false; // Логируем начало поиска this.logger.info(`[userId=%s] Searching dialog...`, resolvedUser?.id); this.logger.debug( { route, role: resolvedUser.role, state: resolvedUser.state, substate: resolvedUser.substate, command, type, contents, params, chat, object, chat_type, }, `[userId=${resolvedUser.id}] Searching dialog criterias:` ); // Поиск подходящего диалога for (const item of this.script) { if ( (item.is_active || ( typeof item.is_active === 'undefined' && ( item.received || item.user || item.chat || item.chat_type || item.state || item.substate || item.role || item.route || item.channel ) )) && (await checkDialog(request, resolvedUser, item)) ) { isDialogFound = true; const ctx = this.#traceContext.get(userId); if (ctx) { ctx.dialogId = item._id; ctx.dialogsProcessed++; } // TRACE: dialog matched this.#trace(userId, 'dialog_matched', { dialogId: item._id, matchCriteria: { state: item.state ?? null, substate: item.substate ?? null, role: item.role ?? null, received: item.received ?? null, chat_type: item.chat_type ?? null, route: item.route ?? null, channel: item.channel ?? null, }, passThrough: item.passThrough || item.pass_through || false, actionsCount: this.#countActions(item.actions), }); // Логируем, если диалог найден this.logger.info( `[userId=${resolvedUser.id}] Found dialog _id=[${item._id}]. Executing...` ); this.logger.debug( item.actions, `[userId=${resolvedUser.id}] Dialog _id=[${item._id}] contents:` ); try { await this.doAction(item.actions, request, user, context, this); } catch (error) { // TRACE: dialog error this.#trace(userId, 'dialog_error', { dialogId: item._id, error: error.message, stack: error.stack, }); this.logger.error(error); } // TRACE: dialog complete this.#trace(userId, 'dialog_complete', { dialogId: item._id }); this.logger.info( `[userId=${resolvedUser.id}] Dialog _id=[${item._id}] finished` ); if (!item.passThrough && !item.pass_through) { break; } } } // Логируем, если диалог не найден if (!isDialogFound) { // TRACE: dialog not found this.#trace(userId, 'dialog_not_found', { searchCriteria: { state: resolvedUser.state, substate: resolvedUser.substate, role: resolvedUser.role, type: request.type, contents: this.#formatValue(request.contents), }, }); this.logger.info(`[userId=${resolvedUser.id}] Dialog not found. Done`); } // TRACE: request complete const ctx = this.#traceContext.get(userId); const finalUser = { state: await resolveValue(user.state), substate: await resolveValue(user.substate), }; this.#trace(userId, 'request_complete', { duration: Date.now() - startTime, dialogsProcessed: ctx?.dialogsProcessed || 0, actionsExecuted: ctx?.actionsCount || 0, finalUser, }); this.#traceContext.delete(userId); this.#logExit(); } } function matchCondition(value, pattern) { if (typeof pattern !== 'string') return true; // Если pattern не задан, любое значение подходит const strValue = value == null ? '' : String(value); return Boolean(strValue.match(new RegExp(pattern, 'i'))); } async function checkDialog(request, user = {}, dialogItem) { if (!dialogItem) return false; // Если dialogItem отсутствует, условия не выполняются const role = user.role instanceof Promise ? await user.role : user.role; const state = user.state instanceof Promise ? await user.state : user.state; const substate = user.substate instanceof Promise ? await user.substate : user.substate; const { chat, type, contents, params, chat_type, route, channel } = request; const item = { ...dialogItem }; if(typeof item.received?.command === 'string') { item.received.text = item.received.command; item.received.type = 'command'; } return ( matchCondition(state, item.state) && matchCondition(substate, item.substate) && matchCondition(role, item.role) && matchCondition(type, item.received?.type) && matchCondition(contents, item.received?.text) && matchCondition(params, item.received?.params) && matchCondition(chat, item.chat) && matchCondition(route, item.route) && matchCondition(channel, item.channel) && matchCondition(chat_type, item.chat_type) && matchCondition(user?.id ?? '', item.user) // Гарантируем, что user.id - строка ); } async function resolveValue(value) { return value instanceof Promise ? await value : value; }