import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../../infra/prisma.service'; import { NotificationsService } from '../notifications.service'; import { BotCallbackRegistryService } from './bot-callback-registry.service'; import { formatTokenForLog } from './bot-debug-log.util'; import { BotFatherService } from './bot-father.service'; import { BotRateLimitService } from './bot-rate-limit.service'; import { BotUpdateQueueService } from './bot-update-queue.service'; import { deriveTelegramChatId, hashBotToken } from './bot-token.util'; import { serializeTelegramResponse, telegramError, telegramOk } from './telegram-response.util'; import { TelegramSerializerService } from './telegram-serializer.service'; import { WebAppCryptoService } from './webapp-crypto.service'; import { BOTFATHER_BOT_USERNAME, buildBotManageWebAppUrl } from './bot-father.constants'; import { menuButtonToJson, parseMenuButtonInput, resolveComposerMenuButton, } from './bot-menu-button.util'; import { assertHumanAccount } from '../system-account.util'; type ValidatedBot = { id: string; name: string; username: string; ownerId: string; webAppUrl?: string | null; menuButton?: unknown; webhookUrl?: string | null; webhookSecretToken?: string | null; isActive: boolean; isSystemBot: boolean; }; type ChatPayload = { chat_id?: string | number; message_id?: number | string; }; type SendMessagePayload = ChatPayload & { text?: string; parse_mode?: string; reply_markup?: unknown; disable_web_page_preview?: boolean; }; type EditMessageTextPayload = ChatPayload & { text?: string; parse_mode?: string; reply_markup?: unknown; }; type EditMessageReplyMarkupPayload = ChatPayload & { reply_markup?: unknown; }; type PinChatMessagePayload = ChatPayload & { disable_notification?: boolean; }; type AnswerCallbackQueryPayload = { callback_query_id?: string; text?: string; show_alert?: boolean; url?: string; }; type MediaPayload = ChatPayload & { photo?: string; document?: string; caption?: string; reply_markup?: unknown; }; type SetWebhookPayload = { url?: string; secret_token?: string; drop_pending_updates?: boolean; }; type GetUpdatesPayload = { offset?: number | string; limit?: number | string; timeout?: number | string; }; type SetChatMenuButtonPayload = { chat_id?: string | number | null; menu_button?: unknown; }; @Injectable() export class BotApiService { private readonly logger = new Logger(BotApiService.name); constructor( private readonly prisma: PrismaService, private readonly botFather: BotFatherService, private readonly rateLimit: BotRateLimitService, private readonly notifications: NotificationsService, private readonly webAppCrypto: WebAppCryptoService, private readonly updateQueue: BotUpdateQueueService, private readonly serializer: TelegramSerializerService, private readonly callbackRegistry: BotCallbackRegistryService ) {} async executeMethod(token: string, method: string, payload: Record) { const bot = (await this.botFather.validateBotToken(token)) as ValidatedBot | null; if (!bot) { this.logger.warn(`Unauthorized Bot API call\n${formatTokenForLog(token)}`); return { httpStatus: 401, responseJson: serializeTelegramResponse(telegramError(401, 'Unauthorized')) }; } const rateCheck = await this.rateLimit.assertBotApiRateLimit(hashBotToken(token)); if (!rateCheck.allowed) { return { httpStatus: 429, responseJson: serializeTelegramResponse( telegramError(429, 'Too Many Requests: retry later', { retry_after: rateCheck.retryAfter }) ) }; } const normalizedMethod = method.trim(); this.logger.debug(`Bot API ${normalizedMethod} bot=@${bot.username}\n${formatTokenForLog(token)}`); switch (normalizedMethod) { case 'getMe': return this.wrapOk(this.buildUserObject(bot)); case 'getChat': return this.getChat(bot, payload as ChatPayload); case 'sendMessage': return this.sendMessage(bot, payload as SendMessagePayload); case 'editMessageText': return this.editMessageText(bot, payload as EditMessageTextPayload); case 'editMessageReplyMarkup': return this.editMessageReplyMarkup(bot, payload as EditMessageReplyMarkupPayload); case 'pinChatMessage': return this.setMessagePinned(bot, payload as PinChatMessagePayload, true); case 'unpinChatMessage': return this.setMessagePinned(bot, payload as ChatPayload, false); case 'answerCallbackQuery': return this.answerCallbackQuery(payload as AnswerCallbackQueryPayload); case 'sendPhoto': return this.sendPhoto(bot, payload as MediaPayload); case 'sendDocument': return this.sendDocument(bot, payload as MediaPayload); case 'setWebhook': return this.setWebhook(bot, payload as SetWebhookPayload); case 'deleteWebhook': return this.deleteWebhook(bot, payload); case 'getWebhookInfo': return this.getWebhookInfo(bot); case 'getUpdates': return this.getUpdates(bot, payload as GetUpdatesPayload); case 'setChatMenuButton': return this.setChatMenuButton(bot, payload as SetChatMenuButtonPayload); default: return { httpStatus: 404, responseJson: serializeTelegramResponse(telegramError(404, `Method '${normalizedMethod}' is not supported`)) }; } } validateWebAppInitData(initData: string, botToken: string) { this.logger.debug(`Validate WebApp initData\n${formatTokenForLog(botToken)}`); const result = this.webAppCrypto.validateWebAppData(initData, botToken); if (!result.valid) { return { valid: false, error: result.error }; } return { valid: true, userJson: result.userJson, authDate: result.authDate }; } async sendInternalBotMessage( botId: string, recipientUserId: string, text: string, replyMarkup?: Record | null ) { const bot = await this.prisma.bot.findUnique({ where: { id: botId }, select: { id: true, name: true, username: true, isActive: true } }); if (!bot?.isActive) { throw new Error('Системный бот недоступен'); } const context = await this.resolveChatContext(bot.id, recipientUserId); if (!context) { throw new Error('Получатель не найден'); } const parsedMarkup = this.serializer.parseReplyMarkup(replyMarkup ?? null); const message = await this.createOutboundMessage(bot.id, context.chat.id, { messageType: 'text', text, payload: this.serializer.buildStoredPayload(parsedMarkup) }); await this.publishBotMessage(context.recipient?.id ?? null, bot as ValidatedBot, context.chat, message, parsedMarkup.internal); return message; } async listBotChatMessages(userId: string, botRef: string, roomId?: string) { const sender = await this.prisma.user.findUnique({ where: { id: userId }, select: { isSystemAccount: true, linkedBotId: true, deletedAt: true, status: true } }); if (!sender || sender.deletedAt || sender.status !== 'ACTIVE') { return { messages: [], botUsername: botRef, botDisplayName: 'Bot' }; } assertHumanAccount(sender, { message: 'Системные учётные записи не могут просматривать чаты ботов' }); const bot = await this.prisma.bot.findFirst({ where: { OR: [ { id: botRef }, { username: botRef }, { username: `${botRef.replace(/_bot$/i, '')}_bot` } ] }, select: { id: true, name: true, username: true, isActive: true, isSystemBot: true, webAppUrl: true, menuButton: true, ownerId: true } }); if (!bot?.isActive) { return { messages: [], botUsername: botRef, botDisplayName: 'Bot', composerWebAppUrl: undefined, composerMenuButtonJson: undefined }; } const privateChat = roomId ? await this.resolvePrivateBotChatForRoom(bot.id, userId, roomId) : await this.prisma.botChat.findUnique({ where: { botId_recipientUserId: { botId: bot.id, recipientUserId: userId } }, include: { menuButton: true } }); const groupChat = roomId ? await this.ensureRoomBotChat(bot.id, roomId) : null; const composer = this.resolveComposerForBot(bot, privateChat?.menuButton?.menuButton ?? null); const emptyResponse = { messages: [] as Array>, botUsername: bot.username, botDisplayName: bot.name, botId: bot.id, botOwnerId: bot.ownerId, composerWebAppUrl: composer.composerWebAppUrl, composerMenuButtonJson: composer.composerMenuButtonJson, manageWebAppUrl: buildBotManageWebAppUrl(bot.id) }; if (!privateChat && !groupChat) { return emptyResponse; } const chatIds = [privateChat?.id, groupChat?.id].filter(Boolean) as string[]; const [outbound, inbound] = await Promise.all([ this.prisma.botMessage.findMany({ where: { botChatId: { in: chatIds } }, orderBy: { createdAt: 'asc' }, include: { botChat: { select: { chatType: true, roomId: true } } } }), privateChat ? this.prisma.botInboundMessage.findMany({ where: { botChatId: privateChat.id, senderUserId: userId }, orderBy: { createdAt: 'asc' } }) : Promise.resolve([]) ]); const messages = [ ...inbound.map((item) => ({ id: `in-${item.id}`, direction: 'in' as const, scope: 'private' as const, text: item.text ?? '', messageType: 'text', messageId: item.telegramMsgId, createdAt: item.createdAt.toISOString(), replyMarkupJson: null as string | null, isPinned: false, pinnedAt: null as string | null })), ...outbound.map((item) => { const payload = this.serializer.readPayload(item.payload); const replyMarkup = payload.telegramReplyMarkup ?? payload.replyMarkup; const isBroadcast = item.botChat.chatType === 'group'; return { id: `${isBroadcast ? 'broadcast' : 'out'}-${item.id}`, direction: 'out' as const, scope: isBroadcast ? ('broadcast' as const) : ('private' as const), text: item.text ?? '', messageType: item.messageType ?? 'text', messageId: item.telegramMsgId, createdAt: item.createdAt.toISOString(), replyMarkupJson: replyMarkup ? JSON.stringify(replyMarkup) : null, isPinned: item.isPinned, pinnedAt: item.pinnedAt?.toISOString() ?? null }; }) ].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); return { messages, botUsername: bot.username, botDisplayName: bot.name, botId: bot.id, botOwnerId: bot.ownerId, composerWebAppUrl: composer.composerWebAppUrl, composerMenuButtonJson: composer.composerMenuButtonJson, manageWebAppUrl: buildBotManageWebAppUrl(bot.id) }; } private resolveComposerForBot( bot: { isSystemBot: boolean; username: string; webAppUrl?: string | null; menuButton?: unknown }, chatOverride: unknown ) { const resolved = resolveComposerMenuButton({ chatOverride, globalMenuButton: bot.menuButton, bot }); return { composerWebAppUrl: resolved.composerWebAppUrl, composerMenuButtonJson: resolved.menuButton ? JSON.stringify(resolved.menuButton) : undefined, buttonText: resolved.buttonText }; } private async resolvePrivateBotChatForRoom(botId: string, viewerUserId: string, roomId: string) { const room = await this.prisma.chatRoom.findUnique({ where: { id: roomId }, include: { members: { include: { user: { select: { linkedBotId: true } } } } } }); if (!room || room.type !== 'BOT' || !room.members.some((member) => member.userId === viewerUserId)) { return null; } const botMember = room.members.find((member) => member.user.linkedBotId === botId); if (!botMember || room.peerUserId !== botMember.userId) { return null; } const existing = await this.prisma.botChat.findUnique({ where: { botId_recipientUserId: { botId, recipientUserId: viewerUserId } }, include: { menuButton: true } }); if (existing) { return existing; } const telegramChatId = deriveTelegramChatId(`${botId}:${viewerUserId}`); try { return await this.prisma.botChat.create({ data: { botId, recipientUserId: viewerUserId, telegramChatId, chatType: 'private' }, include: { menuButton: true } }); } catch { return this.prisma.botChat.findUnique({ where: { botId_recipientUserId: { botId, recipientUserId: viewerUserId } }, include: { menuButton: true } }); } } async ensureRoomBotChat(botId: string, roomId: string) { const existing = await this.prisma.botChat.findUnique({ where: { botId_roomId: { botId, roomId } } }); if (existing) { return existing; } const telegramChatId = deriveTelegramChatId(`${botId}:room:${roomId}`); try { return await this.prisma.botChat.create({ data: { botId, roomId, telegramChatId, chatType: 'group', recipientUserId: null } }); } catch { const fallback = await this.prisma.botChat.findUnique({ where: { botId_roomId: { botId, roomId } } }); if (!fallback) { throw new Error('Не удалось создать групповой чат бота'); } return fallback; } } private async setChatMenuButton(bot: ValidatedBot, payload: SetChatMenuButtonPayload) { const chatIdRaw = payload.chat_id; const parsedMenuButton = parseMenuButtonInput(payload.menu_button); const isGlobal = chatIdRaw === undefined || chatIdRaw === null || (typeof chatIdRaw === 'string' && chatIdRaw.trim() === ''); if (isGlobal) { const stored = menuButtonToJson(parsedMenuButton ?? null); await this.prisma.bot.update({ where: { id: bot.id }, data: { menuButton: stored as never } }); await this.botFather.invalidateBotCacheById(bot.id); const chats = await this.prisma.botChat.findMany({ where: { botId: bot.id, recipientUserId: { not: null } }, include: { menuButton: true } }); await Promise.all( chats.map(async (chat) => { if (!chat.recipientUserId) return; const composer = this.resolveComposerForBot( { ...bot, menuButton: stored }, chat.menuButton?.menuButton ?? null ); await this.publishMenuButtonUpdate(chat.recipientUserId, bot, composer); }) ); return this.wrapOk(true); } const context = await this.resolveChatContext(bot.id, String(chatIdRaw)); if (!context) { return this.wrapBadRequest('Bad Request: chat not found'); } const shouldClear = payload.menu_button === undefined || parsedMenuButton === null || parsedMenuButton === undefined || parsedMenuButton.type === 'default'; if (shouldClear) { await this.prisma.chatMenuButton.deleteMany({ where: { botChatId: context.chat.id } }); } else { const stored = menuButtonToJson(parsedMenuButton); if (!stored) { return this.wrapBadRequest('Bad Request: menu_button is invalid'); } await this.prisma.chatMenuButton.upsert({ where: { botChatId: context.chat.id }, create: { botId: bot.id, botChatId: context.chat.id, menuButton: stored as never }, update: { menuButton: stored as never } }); } const freshBot = await this.prisma.bot.findUnique({ where: { id: bot.id }, select: { menuButton: true, isSystemBot: true, username: true, webAppUrl: true } }); const override = shouldClear ? null : (await this.prisma.chatMenuButton.findUnique({ where: { botChatId: context.chat.id } }))?.menuButton ?? null; const composer = this.resolveComposerForBot( { isSystemBot: freshBot?.isSystemBot ?? bot.isSystemBot, username: freshBot?.username ?? bot.username, webAppUrl: freshBot?.webAppUrl ?? bot.webAppUrl, menuButton: freshBot?.menuButton }, override ); if (context.recipient) { await this.publishMenuButtonUpdate(context.recipient.id, bot, composer); } return this.wrapOk(true); } private async publishMenuButtonUpdate( userId: string, bot: ValidatedBot, composer: { composerWebAppUrl?: string; composerMenuButtonJson?: string; buttonText?: string } ) { await this.notifications.publishRealtime(userId, 'bot_menu_button_updated', bot.name, '', { botId: bot.id, botUsername: bot.username, composerWebAppUrl: composer.composerWebAppUrl ?? null, composerMenuButtonJson: composer.composerMenuButtonJson ?? null, buttonText: composer.buttonText ?? null }); } private async sendMessage(bot: ValidatedBot, payload: SendMessagePayload) { const chatIdRaw = payload.chat_id; const text = payload.text?.trim(); if (chatIdRaw === undefined || chatIdRaw === null || chatIdRaw === '') { return this.wrapBadRequest('Bad Request: chat_id is required'); } if (!text) { return this.wrapBadRequest('Bad Request: text is required'); } const context = await this.resolveChatContext(bot.id, String(chatIdRaw)); if (!context) { return this.wrapBadRequest('Bad Request: chat not found'); } const parsedMarkup = this.serializer.parseReplyMarkup(payload.reply_markup); const message = await this.createOutboundMessage(bot.id, context.chat.id, { messageType: 'text', text, payload: this.serializer.buildStoredPayload(parsedMarkup) }); await this.publishBotMessage(context.recipient?.id ?? null, bot, context.chat, message, parsedMarkup.internal); return this.wrapOk(this.buildTelegramMessage(bot, context, message)); } private async sendPhoto(bot: ValidatedBot, payload: MediaPayload) { const photo = payload.photo?.trim(); if (!payload.chat_id || !photo) { return this.wrapBadRequest('Bad Request: chat_id and photo are required'); } const context = await this.resolveChatContext(bot.id, String(payload.chat_id)); if (!context) { return this.wrapBadRequest('Bad Request: chat not found'); } const parsedMarkup = this.serializer.parseReplyMarkup(payload.reply_markup); const message = await this.createOutboundMessage(bot.id, context.chat.id, { messageType: 'photo', text: payload.caption?.trim() || null, mediaUrl: photo, payload: this.serializer.buildStoredPayload(parsedMarkup) }); await this.publishBotMessage(context.recipient?.id ?? null, bot, context.chat, message, parsedMarkup.internal, 'photo', photo); return this.wrapOk(this.buildTelegramMessage(bot, context, message)); } private async sendDocument(bot: ValidatedBot, payload: MediaPayload) { const document = payload.document?.trim(); if (!payload.chat_id || !document) { return this.wrapBadRequest('Bad Request: chat_id and document are required'); } const context = await this.resolveChatContext(bot.id, String(payload.chat_id)); if (!context) { return this.wrapBadRequest('Bad Request: chat not found'); } const parsedMarkup = this.serializer.parseReplyMarkup(payload.reply_markup); const message = await this.createOutboundMessage(bot.id, context.chat.id, { messageType: 'document', text: payload.caption?.trim() || null, mediaUrl: document, payload: this.serializer.buildStoredPayload(parsedMarkup) }); await this.publishBotMessage(context.recipient?.id ?? null, bot, context.chat, message, parsedMarkup.internal, 'document', document); return this.wrapOk(this.buildTelegramMessage(bot, context, message)); } private async editMessageText(bot: ValidatedBot, payload: EditMessageTextPayload) { const text = payload.text?.trim(); if (!payload.chat_id || payload.message_id === undefined) { return this.wrapBadRequest('Bad Request: chat_id and message_id are required'); } if (!text) { return this.wrapBadRequest('Bad Request: text is required'); } const messageId = Number(payload.message_id); if (!Number.isFinite(messageId)) { return this.wrapBadRequest('Bad Request: message_id is invalid'); } const located = await this.locateMessage(bot.id, String(payload.chat_id), messageId); if (!located) { return this.wrapBadRequest('Bad Request: message to edit not found'); } const parsedMarkup = payload.reply_markup ? this.serializer.parseReplyMarkup(payload.reply_markup) : this.serializer.readPayloadAsParsed(located.message.payload); const updated = await this.prisma.botMessage.update({ where: { id: located.message.id }, data: { text, editedAt: new Date(), payload: this.mergePayload(located.message.payload, parsedMarkup) } }); await this.publishBotMessageEdited(located.recipient?.id ?? null, bot, located.chat, updated, parsedMarkup.internal); return this.wrapOk(this.buildTelegramMessage(bot, located, updated)); } private async editMessageReplyMarkup(bot: ValidatedBot, payload: EditMessageReplyMarkupPayload) { if (!payload.chat_id || payload.message_id === undefined || payload.reply_markup === undefined) { return this.wrapBadRequest('Bad Request: chat_id, message_id and reply_markup are required'); } const messageId = Number(payload.message_id); if (!Number.isFinite(messageId)) { return this.wrapBadRequest('Bad Request: message_id is invalid'); } const located = await this.locateMessage(bot.id, String(payload.chat_id), messageId); if (!located) { return this.wrapBadRequest('Bad Request: message to edit not found'); } const parsedMarkup = this.serializer.parseReplyMarkup(payload.reply_markup); const updated = await this.prisma.botMessage.update({ where: { id: located.message.id }, data: { editedAt: new Date(), payload: this.mergePayload(located.message.payload, parsedMarkup) } }); await this.publishBotMessageEdited(located.recipient?.id ?? null, bot, located.chat, updated, parsedMarkup.internal); return this.wrapOk(this.buildTelegramMessage(bot, located, updated)); } private async getChat(bot: ValidatedBot, payload: ChatPayload) { if (!payload.chat_id) { return this.wrapBadRequest('Bad Request: chat_id is required'); } const context = await this.resolveChatContext(bot.id, String(payload.chat_id)); if (!context) { return this.wrapBadRequest('Bad Request: chat not found'); } const pinned = await this.prisma.botMessage.findFirst({ where: { botId: bot.id, botChatId: context.chat.id, isPinned: true }, orderBy: { pinnedAt: 'desc' } }); return this.wrapOk({ id: Number(context.chat.telegramChatId), type: context.chat.chatType, first_name: context.recipient?.displayName ?? bot.name, ...(context.recipient?.username ? { username: context.recipient.username } : {}), ...(pinned ? { pinned_message: this.buildTelegramMessage(bot, context, pinned) } : {}) }); } private async setMessagePinned(bot: ValidatedBot, payload: ChatPayload, pinned: boolean) { if (!payload.chat_id) { return this.wrapBadRequest('Bad Request: chat_id is required'); } if (pinned && payload.message_id === undefined) { return this.wrapBadRequest('Bad Request: message_id is required'); } const messageId = payload.message_id === undefined ? undefined : Number(payload.message_id); if (messageId !== undefined && !Number.isFinite(messageId)) { return this.wrapBadRequest('Bad Request: message_id is invalid'); } const context = await this.resolveChatContext(bot.id, String(payload.chat_id)); if (!context) { return this.wrapBadRequest('Bad Request: chat not found'); } const message = messageId === undefined ? await this.prisma.botMessage.findFirst({ where: { botId: bot.id, botChatId: context.chat.id, isPinned: true }, orderBy: { pinnedAt: 'desc' } }) : await this.prisma.botMessage.findFirst({ where: { botId: bot.id, botChatId: context.chat.id, telegramMsgId: messageId } }); if (!message) { return this.wrapBadRequest(pinned ? 'Bad Request: message to pin not found' : 'Bad Request: message to unpin not found'); } const updated = await this.prisma.botMessage.update({ where: { id: message.id }, data: pinned ? { isPinned: true, pinnedAt: new Date() } : { isPinned: false, pinnedAt: null } }); await this.publishBotMessagePinned(context.recipient?.id ?? null, bot, context.chat, updated); return this.wrapOk(true); } private async answerCallbackQuery(payload: AnswerCallbackQueryPayload) { const callbackQueryId = payload.callback_query_id?.trim(); if (!callbackQueryId) { return this.wrapBadRequest('Bad Request: callback_query_id is required'); } const registered = await this.callbackRegistry.resolve(callbackQueryId); if (!registered) { return this.wrapBadRequest('Bad Request: callback query not found or expired'); } await this.notifications.publishRealtime(registered.userId, 'bot_callback_answer', '', payload.text?.trim() ?? '', { callbackQueryId, text: payload.text?.trim() ?? null, showAlert: Boolean(payload.show_alert), url: payload.url?.trim() ?? null, botId: registered.botId }); return this.wrapOk(true); } private async setWebhook(bot: ValidatedBot, payload: SetWebhookPayload) { const url = payload.url?.trim(); if (!url) { return this.wrapBadRequest('Bad Request: url is required'); } if (!/^https?:\/\//i.test(url)) { return this.wrapBadRequest('Bad Request: invalid webhook url'); } if (payload.drop_pending_updates) { await this.updateQueue.clearQueue(bot.id); await this.prisma.botUpdate.deleteMany({ where: { botId: bot.id } }); } await this.prisma.bot.update({ where: { id: bot.id }, data: { webhookUrl: url, webhookSecretToken: payload.secret_token?.trim() || null } }); await this.botFather.invalidateBotCacheById(bot.id); return this.wrapOk(true); } private async deleteWebhook(bot: ValidatedBot, payload: Record) { if (payload.drop_pending_updates) { await this.updateQueue.clearQueue(bot.id); await this.prisma.botUpdate.deleteMany({ where: { botId: bot.id } }); } await this.prisma.bot.update({ where: { id: bot.id }, data: { webhookUrl: null, webhookSecretToken: null } }); await this.botFather.invalidateBotCacheById(bot.id); return this.wrapOk(true); } private async getWebhookInfo(bot: ValidatedBot) { const record = await this.prisma.bot.findUnique({ where: { id: bot.id }, select: { webhookUrl: true } }); const pending = record?.webhookUrl ? 0 : await this.updateQueue.pendingCount(bot.id); return this.wrapOk({ url: record?.webhookUrl ?? '', has_custom_certificate: false, pending_update_count: pending, max_connections: 40 }); } private async getUpdates(bot: ValidatedBot, payload: GetUpdatesPayload) { const record = await this.prisma.bot.findUnique({ where: { id: bot.id }, select: { webhookUrl: true } }); if (record?.webhookUrl) { return this.wrapBadRequest("Bad Request: can't use getUpdates while webhook is active; use deleteWebhook first"); } const offset = payload.offset !== undefined ? Number(payload.offset) : 0; const limit = payload.limit !== undefined ? Number(payload.limit) : 100; const timeout = payload.timeout !== undefined ? Number(payload.timeout) : 0; if (!Number.isFinite(offset) || offset < 0) { return this.wrapBadRequest('Bad Request: offset is invalid'); } if (!Number.isFinite(limit) || limit <= 0) { return this.wrapBadRequest('Bad Request: limit is invalid'); } if (!Number.isFinite(timeout) || timeout < 0) { return this.wrapBadRequest('Bad Request: timeout is invalid'); } const updates = await this.updateQueue.getUpdates(bot.id, offset, limit, timeout); return this.wrapOk(updates); } private async createOutboundMessage( botId: string, botChatId: string, data: { messageType: string; text?: string | null; mediaUrl?: string | null; payload?: Record; } ) { return this.prisma.$transaction(async (tx) => { const currentBot = await tx.bot.update({ where: { id: botId }, data: { nextMessageId: { increment: 1 } }, select: { nextMessageId: true } }); const telegramMsgId = currentBot.nextMessageId - 1; return tx.botMessage.create({ data: { botId, botChatId, telegramMsgId, messageType: data.messageType, text: data.text ?? null, mediaUrl: data.mediaUrl ?? null, payload: data.payload as never } }); }); } private async locateMessage(botId: string, chatIdRaw: string, telegramMsgId: number) { const context = await this.resolveChatContext(botId, chatIdRaw); if (!context) return null; const message = await this.prisma.botMessage.findFirst({ where: { botId, botChatId: context.chat.id, telegramMsgId } }); if (!message) return null; return { ...context, message }; } private async resolveChatContext(botId: string, chatIdRaw: string) { const numericChatId = chatIdRaw.match(/^\d+$/) ? BigInt(chatIdRaw) : null; if (numericChatId) { const chat = await this.prisma.botChat.findUnique({ where: { botId_telegramChatId: { botId, telegramChatId: numericChatId } } }); if (!chat) { return null; } if (chat.chatType === 'group') { return { recipient: null, chat }; } if (!chat.recipientUserId) { return null; } const recipient = await this.prisma.user.findUnique({ where: { id: chat.recipientUserId }, select: { id: true, displayName: true, username: true, status: true, deletedAt: true } }); if (!recipient || recipient.deletedAt || recipient.status !== 'ACTIVE') { return null; } return { recipient, chat }; } const recipient = await this.resolveRecipient(chatIdRaw, botId); if (!recipient) { return null; } const chat = await this.ensureBotChat(botId, recipient.id, chatIdRaw); return { recipient, chat }; } private buildTelegramMessage( bot: ValidatedBot, context: { recipient: { id: string; displayName: string; username: string | null } | null; chat: { telegramChatId: bigint; chatType: string }; }, message: { telegramMsgId: number; text?: string | null; messageType?: string | null; mediaUrl?: string | null; createdAt: Date; editedAt?: Date | null; payload?: unknown; isPinned?: boolean; pinnedAt?: Date | null; } ) { return this.serializer.buildBotMessageObject({ message, bot, chat: { telegramChatId: context.chat.telegramChatId, chatType: context.chat.chatType, recipientDisplayName: context.recipient?.displayName ?? bot.name, recipientUsername: context.recipient?.username ?? null } }); } private async publishBotMessage( userId: string | null, bot: ValidatedBot, chat: { telegramChatId: bigint; chatType: string; roomId?: string | null }, message: { telegramMsgId: number; text?: string | null; messageType?: string | null; mediaUrl?: string | null; payload?: unknown; }, replyMarkup: unknown, mediaType?: string, mediaUrl?: string ) { const payloadMarkup = this.serializer.readPayload(message.payload); const payload = { botId: bot.id, botUsername: bot.username, chatId: chat.telegramChatId.toString(), messageId: message.telegramMsgId, text: message.text ?? null, messageType: message.messageType ?? mediaType ?? 'text', mediaUrl: message.mediaUrl ?? mediaUrl ?? null, replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null, telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null, scope: chat.chatType === 'group' ? 'broadcast' : 'private', roomId: chat.roomId ?? null }; if (chat.chatType === 'group' && chat.roomId) { await this.publishToRoomMembers(chat.roomId, bot, 'bot_message', message.text ?? '', payload); return; } if (userId) { await this.notifications.publishRealtime(userId, 'bot_message', bot.name, message.text ?? '', payload); } } private async publishBotMessagePinned( userId: string | null, bot: ValidatedBot, chat: { telegramChatId: bigint; chatType: string; roomId?: string | null }, message: { telegramMsgId: number; isPinned?: boolean; pinnedAt?: Date | null; } ) { const payload = { botId: bot.id, botUsername: bot.username, chatId: chat.telegramChatId.toString(), messageId: message.telegramMsgId, isPinned: Boolean(message.isPinned), pinnedAt: message.pinnedAt?.toISOString() ?? null, scope: chat.chatType === 'group' ? 'broadcast' : 'private', roomId: chat.roomId ?? null }; if (chat.chatType === 'group' && chat.roomId) { await this.publishToRoomMembers(chat.roomId, bot, 'bot_message_pinned', '', payload); return; } if (userId) { await this.notifications.publishRealtime(userId, 'bot_message_pinned', bot.name, '', payload); } } private async publishBotMessageEdited( userId: string | null, bot: ValidatedBot, chat: { telegramChatId: bigint; chatType: string; roomId?: string | null }, message: { telegramMsgId: number; text?: string | null; messageType?: string | null; mediaUrl?: string | null; payload?: unknown; editedAt?: Date | null; }, replyMarkup: unknown ) { const payloadMarkup = this.serializer.readPayload(message.payload); const payload = { botId: bot.id, botUsername: bot.username, chatId: chat.telegramChatId.toString(), messageId: message.telegramMsgId, text: message.text ?? null, messageType: message.messageType ?? 'text', mediaUrl: message.mediaUrl ?? null, replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null, telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null, editedAt: message.editedAt?.toISOString() ?? new Date().toISOString(), scope: chat.chatType === 'group' ? 'broadcast' : 'private', roomId: chat.roomId ?? null }; if (chat.chatType === 'group' && chat.roomId) { await this.publishToRoomMembers(chat.roomId, bot, 'bot_message_edited', message.text ?? '', payload); return; } if (userId) { await this.notifications.publishRealtime(userId, 'bot_message_edited', bot.name, message.text ?? '', payload); } } private async publishToRoomMembers( roomId: string, bot: ValidatedBot, eventType: string, message: string, payload: Record ) { const room = await this.prisma.chatRoom.findUnique({ where: { id: roomId }, include: { members: { include: { user: { select: { linkedBotId: true } } } } } }); if (!room || room.type !== 'BOT') { return; } const recipients = room.members .filter((member) => !member.user.linkedBotId) .map((member) => member.userId); await Promise.all( recipients.map((recipientId) => this.notifications.publishRealtime(recipientId, eventType, bot.name, message, payload) ) ); } private mergePayload(existing: unknown, parsed: ReturnType) { const current = existing && typeof existing === 'object' ? (existing as Record) : {}; const stored = this.serializer.buildStoredPayload(parsed); return (stored ? { ...current, ...stored } : current) as never; } private async resolveRecipient(chatId: string, botId: string) { const numericChatId = chatId.match(/^\d+$/) ? BigInt(chatId) : null; if (numericChatId) { const chat = await this.prisma.botChat.findUnique({ where: { botId_telegramChatId: { botId, telegramChatId: numericChatId } } }); if (!chat?.recipientUserId) return null; return this.prisma.user.findUnique({ where: { id: chat.recipientUserId }, select: { id: true, displayName: true, username: true, status: true, deletedAt: true } }); } const user = await this.prisma.user.findUnique({ where: { id: chatId }, select: { id: true, displayName: true, username: true, status: true, deletedAt: true } }); if (!user || user.deletedAt || user.status !== 'ACTIVE') { return null; } return user; } private async ensureBotChat(botId: string, recipientUserId: string, chatIdRaw: string) { const existing = await this.prisma.botChat.findUnique({ where: { botId_recipientUserId: { botId, recipientUserId } } }); if (existing) return existing; const telegramChatId = chatIdRaw.match(/^\d+$/) ? BigInt(chatIdRaw) : deriveTelegramChatId(`${botId}:${recipientUserId}`); try { return await this.prisma.botChat.create({ data: { botId, recipientUserId, telegramChatId, chatType: 'private' } }); } catch { const fallback = await this.prisma.botChat.findUnique({ where: { botId_recipientUserId: { botId, recipientUserId } } }); if (!fallback) { throw new Error('Не удалось создать чат бота'); } return fallback; } } private buildUserObject(bot: ValidatedBot) { return { id: Number(deriveTelegramChatId(bot.id)), is_bot: true, first_name: bot.name, username: bot.username, can_join_groups: true, can_read_all_group_messages: false, supports_inline_queries: false }; } private wrapOk(result: unknown) { return { httpStatus: 200, responseJson: serializeTelegramResponse(telegramOk(result)) }; } private wrapBadRequest(description: string) { return { httpStatus: 200, responseJson: serializeTelegramResponse(telegramError(400, description)) }; } }