UNPKG

tg-bot-builder

Version:

Modular NestJS builder for multi-step Telegram bots with Prisma persistence and pluggable session storage.

555 lines 22.6 kB
"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