tg-bot-builder
Version:
Modular NestJS builder for multi-step Telegram bots with Prisma persistence and pluggable session storage.
555 lines • 22.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.BotRuntime = void 0;
exports.normalizeBotOptions = normalizeBotOptions;
const TelegramBot = require("node-telegram-bot-api");
const builder_messages_1 = require("./builder.messages");
const page_navigator_1 = require("./runtime/page-navigator");
const session_manager_1 = require("./runtime/session-manager");
const persistence_gateway_1 = require("./runtime/persistence-gateway");
const middleware_pipeline_1 = require("./runtime/middleware-pipeline");
const serialization_1 = require("./utils/serialization");
const util_1 = require("util");
function normalizeBotOptions(options, index) {
const pages = [...(options.pages ?? [])];
const handlers = [...(options.handlers ?? [])];
const middlewares = [...(options.middlewares ?? [])];
const keyboards = [...(options.keyboards ?? [])];
const services = { ...(options.services ?? {}) };
const pageMiddlewares = [...(options.pageMiddlewares ?? [])];
const slug = options.slug ?? 'default';
const fallbackId = options.id ??
(typeof options.slug === 'string' && options.slug.length > 0
? options.slug
: undefined) ??
options.TG_BOT_TOKEN ??
(index !== undefined ? `bot-${index}` : undefined);
if (!fallbackId) {
throw new Error(builder_messages_1.DEFAULT_BOT_RUNTIME_MESSAGES.botIdResolutionFailed());
}
const dependencies = options.dependencies !== undefined
? { ...options.dependencies }
: undefined;
return {
...options,
id: fallbackId,
pages,
handlers,
middlewares,
keyboards,
services,
pageMiddlewares,
slug,
dependencies,
};
}
class BotRuntime {
constructor(options, logger, dependencies = {}) {
this.handleMessage = async (message, metadata) => {
try {
const chatId = message.chat.id;
const session = await this.sessionManager.getSession(chatId);
if (message.from) {
session.user = message.from;
}
session.data = session.data ?? {};
const { database, buildContext } = await this.prepareContext({
chatId,
session,
message,
metadata,
user: session.user,
});
if (!session.pageId) {
await this.startFromInitialPage({
chatId,
session,
message,
metadata,
});
return;
}
const currentPage = this.pageNavigator.resolvePage(session.pageId);
if (!currentPage) {
this.logger.warn(this.messages.pageNotFound({
pageId: session.pageId,
chatId,
}));
await this.resetToInitialPage(chatId, session);
return;
}
const context = buildContext();
const value = this.pageNavigator.extractMessageValue(message);
const validationResult = await this.pageNavigator.validatePageValue(currentPage, value, context);
if (validationResult.redirectTo) {
if (validationResult.saveValue) {
session.data[currentPage.id] = value;
const updatedStepState = await this.persistenceGateway.persistStepProgress(database.stepState, currentPage.id, value);
if (updatedStepState) {
database.stepState = updatedStepState;
}
const synchronizedStepState = await this.persistenceGateway.syncSessionState(database.stepState, session.data);
if (synchronizedStepState) {
database.stepState = synchronizedStepState;
}
}
await this.advanceToNextPage({
chatId,
session,
nextPageId: validationResult.redirectTo,
database,
buildContext,
});
return;
}
if (!validationResult.valid) {
await this.processValidationFailure({
chatId,
page: currentPage,
errorMessage: validationResult.errorMessage,
session,
database,
buildContext,
});
return;
}
session.data[currentPage.id] = value;
const updatedStepState = await this.persistenceGateway.persistStepProgress(database.stepState, currentPage.id, value);
if (updatedStepState) {
database.stepState = updatedStepState;
}
if (currentPage.onValid) {
await currentPage.onValid(buildContext());
}
const synchronizedStepState = await this.persistenceGateway.syncSessionState(database.stepState, session.data);
if (synchronizedStepState) {
database.stepState = synchronizedStepState;
}
const nextPageId = await this.pageNavigator.resolveNextPageId(currentPage, buildContext());
await this.advanceToNextPage({
chatId,
session,
nextPageId,
database,
buildContext,
});
}
catch (error) {
this.logger.error(this.messages.messageHandlingError({ error }));
}
};
this.id = options.id;
this.token = options.TG_BOT_TOKEN;
this.bot = new TelegramBot(this.token, { polling: true });
this.logger = logger;
const resolvedDependencies = {
...dependencies,
...(options.dependencies ?? {}),
};
const messageFactory = resolvedDependencies.messageFactory ?? builder_messages_1.createBotRuntimeMessages;
this.messages = messageFactory(options.messages);
this.helperServices = options.services ?? {};
this.globalMiddlewares = (0, middleware_pipeline_1.sortMiddlewareConfigs)(options.middlewares ?? []);
const providedSessionStorage = options.sessionStorage;
const sessionManagerFactory = resolvedDependencies.sessionManagerFactory ?? session_manager_1.createSessionManager;
this.sessionManager = sessionManagerFactory({
sessionStorage: providedSessionStorage,
});
const prisma = options.prisma;
const persistenceGatewayFactory = resolvedDependencies.persistenceGatewayFactory ??
persistence_gateway_1.createPersistenceGateway;
this.persistenceGateway = persistenceGatewayFactory({
prisma,
slug: options.slug ?? 'default',
});
const pageNavigatorFactory = resolvedDependencies.pageNavigatorFactory ?? page_navigator_1.createPageNavigator;
this.pageNavigator = pageNavigatorFactory({
bot: this.bot,
logger: this.logger,
initialPageId: options.initialPageId,
keyboards: options.keyboards ?? [],
pageMiddlewares: options.pageMiddlewares ?? [],
});
this.pageNavigator.registerPages(options.pages ?? []);
this.logger.log(this.messages.runtimeInitialized({ id: this.id }));
this.registerHandlers(options.handlers ?? []);
}
registerHandlers(handlers = []) {
this.bot.on('message', this.handleMessage);
if (!Array.isArray(handlers) || handlers.length === 0) {
return;
}
for (const handler of handlers) {
if (!handler || typeof handler.event !== 'string') {
this.logger.warn(this.messages.invalidHandler());
continue;
}
if (typeof handler.listener !== 'function') {
this.logger.warn(this.messages.handlerMissingListener({
event: String(handler.event),
}));
continue;
}
const handlerMiddlewares = (0, middleware_pipeline_1.sortMiddlewareConfigs)(handler.middlewares ?? []);
const combinedMiddlewares = (0, middleware_pipeline_1.mergeMiddlewareConfigs)(this.globalMiddlewares, handlerMiddlewares);
const pipeline = (0, middleware_pipeline_1.buildMiddlewarePipeline)({
event: handler.event,
middlewares: combinedMiddlewares,
handler: async (...args) => {
await Promise.resolve(handler.listener(...args));
},
contextFactory: (event, args) => this.buildMiddlewareContext(event, args),
onError: (error) => this.logMiddlewareError(handler.event, error),
});
this.bot.on(handler.event, pipeline);
}
}
async buildMiddlewareContext(event, args) {
const message = this.extractMessageFromArgs(args);
const metadata = this.extractMetadataFromArgs(args, message);
const user = this.resolveUserFromArgs(args, message);
const chatId = this.resolveChatIdFromArgs(args, message, user);
if (chatId !== undefined) {
const session = await this.sessionManager.getSession(chatId);
if (user) {
session.user = user;
}
const { database, buildContext } = await this.prepareContext({
chatId,
session,
message,
metadata,
user,
});
const context = buildContext({ message, metadata, user });
return {
...context,
db: database,
event,
args,
};
}
return {
botId: this.id,
bot: this.bot,
chatId: 'unknown',
message,
metadata,
session: undefined,
user,
prisma: this.persistenceGateway.prisma,
db: undefined,
services: this.helperServices,
event,
args,
};
}
logMiddlewareError(event, error) {
this.logger.error(this.messages.middlewareError({ event, error }));
}
extractMessageFromArgs(args) {
for (const arg of args) {
const message = this.findMessageInValue(arg);
if (message) {
return message;
}
}
return undefined;
}
findMessageInValue(value, visited = new Set()) {
if (!value || typeof value !== 'object') {
return undefined;
}
if (visited.has(value)) {
return undefined;
}
visited.add(value);
const record = value;
if ('message_id' in record && 'chat' in record) {
return value;
}
if ('message' in record) {
return this.findMessageInValue(record.message, visited);
}
return undefined;
}
extractMetadataFromArgs(args, message) {
if (args.length < 2) {
return undefined;
}
const candidate = args[1];
if (!candidate ||
typeof candidate !== 'object' ||
candidate === message) {
return undefined;
}
return candidate;
}
resolveChatIdFromArgs(args, message, user) {
if (message?.chat?.id !== undefined) {
return message.chat.id;
}
for (const arg of args) {
const chatId = this.extractChatIdFromValue(arg);
if (chatId !== undefined) {
return chatId;
}
}
if (user?.id !== undefined) {
return user.id;
}
return undefined;
}
extractChatIdFromValue(value) {
if (!value || typeof value !== 'object') {
return undefined;
}
const record = value;
if ('chat' in record) {
const chat = record.chat;
if (chat && chat.id !== undefined) {
return chat.id;
}
}
if ('message' in record) {
const nested = this.extractChatIdFromValue(record.message);
if (nested !== undefined) {
return nested;
}
}
if ('from' in record) {
const from = record.from;
if (from && from.id !== undefined) {
return from.id;
}
}
return undefined;
}
resolveUserFromArgs(args, message) {
if (message?.from) {
return message.from;
}
for (const arg of args) {
const user = this.extractUserFromValue(arg);
if (user) {
return user;
}
}
return undefined;
}
extractUserFromValue(value) {
if (!value || typeof value !== 'object') {
return undefined;
}
const record = value;
if ('from' in record) {
const from = record.from;
if (from) {
return from;
}
}
if ('message' in record) {
return this.extractUserFromValue(record.message);
}
return undefined;
}
async startFromInitialPage(options) {
const initialPage = this.pageNavigator.resolveInitialPage();
if (!initialPage) {
this.logger.warn(this.messages.noInitialPage());
return;
}
options.session.data = options.session.data ?? {};
const { database, buildContext } = await this.prepareContext({
chatId: options.chatId,
session: options.session,
message: options.message,
metadata: options.metadata,
});
let targetPage;
if (options.session.pageId) {
targetPage = this.pageNavigator.resolvePage(options.session.pageId);
}
let shouldPersistPageChange = false;
if (!targetPage) {
options.session.pageId = initialPage.id;
shouldPersistPageChange = true;
targetPage = initialPage;
}
const renderedPageId = await this.pageNavigator.renderPage(targetPage, buildContext());
const finalPageId = renderedPageId ?? targetPage.id;
if (options.session.pageId !== finalPageId) {
options.session.pageId = finalPageId;
shouldPersistPageChange = true;
}
if (shouldPersistPageChange) {
await this.sessionManager.saveSession(options.chatId, options.session);
const nextStepState = await this.persistenceGateway.updateStepStateCurrentPage(database.stepState, finalPageId);
if (nextStepState) {
database.stepState = nextStepState;
}
}
}
async prepareContext(options) {
const database = await this.persistenceGateway.ensureDatabaseState(options.chatId, options.session, options.message, options.pageId ?? options.session.pageId);
await this.hydrateSessionFromStepState({
chatId: options.chatId,
session: options.session,
stepState: database.stepState,
});
const buildContext = this.createContextBuilder({
chatId: options.chatId,
session: options.session,
database,
message: options.message,
metadata: options.metadata,
user: options.user,
});
return { database, buildContext };
}
createContextBuilder(options) {
return (overrides = {}) => {
const hasMessageOverride = Object.prototype.hasOwnProperty.call(overrides, 'message');
const hasMetadataOverride = Object.prototype.hasOwnProperty.call(overrides, 'metadata');
const hasUserOverride = Object.prototype.hasOwnProperty.call(overrides, 'user');
return this.createContext({
chatId: options.chatId,
session: options.session,
database: options.database,
message: hasMessageOverride
? overrides.message
: options.message,
metadata: hasMetadataOverride
? overrides.metadata
: options.metadata,
user: hasUserOverride ? overrides.user : options.user,
});
};
}
async hydrateSessionFromStepState(options) {
if (!options.stepState) {
return;
}
const persistedPageId = this.normalizePersistedPageId(options.stepState.currentPage);
const persistedAnswers = (0, serialization_1.normalizeAnswers)(options.stepState.answers, null);
const existingSessionData = options.session.data ?? {};
const mergedSessionData = {
...existingSessionData,
...persistedAnswers,
};
let sessionChanged = false;
if (options.session.pageId !== persistedPageId) {
options.session.pageId = persistedPageId;
sessionChanged = true;
}
if (!(0, util_1.isDeepStrictEqual)(existingSessionData, mergedSessionData)) {
options.session.data = mergedSessionData;
sessionChanged = true;
}
else if (!options.session.data) {
options.session.data = mergedSessionData;
}
if (sessionChanged) {
await this.sessionManager.saveSession(options.chatId, options.session);
}
}
normalizePersistedPageId(pageId) {
if (typeof pageId !== 'string') {
return undefined;
}
const trimmed = pageId.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
async processValidationFailure(options) {
const errorMessage = options.errorMessage ?? this.messages.validationFailed();
await this.bot.sendMessage(options.chatId, errorMessage);
const renderedPageId = await this.pageNavigator.renderPage(options.page, options.buildContext({ message: undefined, metadata: undefined }));
const finalPageId = renderedPageId ?? options.page.id;
if (options.session.pageId !== finalPageId) {
options.session.pageId = finalPageId;
await this.sessionManager.saveSession(options.chatId, options.session);
const nextStepState = await this.persistenceGateway.updateStepStateCurrentPage(options.database.stepState, finalPageId);
if (nextStepState) {
options.database.stepState = nextStepState;
}
}
}
async advanceToNextPage(options) {
if (!options.nextPageId) {
options.session.pageId = undefined;
await this.sessionManager.saveSession(options.chatId, options.session);
await this.persistenceGateway.updateStepStateCurrentPage(options.database.stepState, undefined);
return;
}
const nextPage = this.pageNavigator.resolvePage(options.nextPageId);
if (!nextPage) {
this.logger.warn(this.messages.nextPageNotFound({
pageId: options.nextPageId,
chatId: options.chatId,
}));
options.session.pageId = undefined;
await this.sessionManager.saveSession(options.chatId, options.session);
await this.persistenceGateway.updateStepStateCurrentPage(options.database.stepState, undefined);
return;
}
const previousPageId = options.session.pageId;
if (previousPageId === options.nextPageId) {
return;
}
options.session.pageId = nextPage.id;
const renderedPageId = await this.pageNavigator.renderPage(nextPage, options.buildContext({ message: undefined, metadata: undefined }));
const finalPageId = renderedPageId ?? nextPage.id;
options.session.pageId = finalPageId;
if (previousPageId !== finalPageId) {
await this.sessionManager.saveSession(options.chatId, options.session);
const nextStepState = await this.persistenceGateway.updateStepStateCurrentPage(options.database.stepState, finalPageId);
if (nextStepState) {
options.database.stepState = nextStepState;
}
}
}
async resetToInitialPage(chatId, session) {
const initialPage = this.pageNavigator.resolveInitialPage();
if (!initialPage) {
session.pageId = undefined;
await this.sessionManager.saveSession(chatId, session);
const { database } = await this.prepareContext({
chatId,
session,
});
await this.persistenceGateway.updateStepStateCurrentPage(database.stepState, undefined);
return;
}
session.pageId = initialPage.id;
const { database, buildContext } = await this.prepareContext({
chatId,
session,
pageId: initialPage.id,
});
const renderedPageId = await this.pageNavigator.renderPage(initialPage, buildContext());
const finalPageId = renderedPageId ?? initialPage.id;
session.pageId = finalPageId;
await this.sessionManager.saveSession(chatId, session);
const nextStepState = await this.persistenceGateway.updateStepStateCurrentPage(database.stepState, finalPageId);
if (nextStepState) {
database.stepState = nextStepState;
}
}
createContext(options) {
const user = options.user ?? options.message?.from ?? options.session.user;
return {
botId: this.id,
bot: this.bot,
chatId: options.chatId,
message: options.message,
metadata: options.metadata,
session: options.session.data,
user,
prisma: this.persistenceGateway.prisma,
db: options.database,
services: this.helperServices,
};
}
}
exports.BotRuntime = BotRuntime;
//# sourceMappingURL=bot-runtime.js.map