vvlad1973-telegram-framework
Version:
Current version: *7.9.5*
723 lines (629 loc) • 21.8 kB
JavaScript
;
/**
* 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;
}