import { Injectable, Logger } from '@nestjs/common'; import { deriveTelegramChatId } from './bot-token.util'; export type InternalInlineButton = { text: string; callbackData?: string; url?: string; webAppUrl?: string; }; export type InternalReplyButton = { text: string; requestContact?: boolean; requestLocation?: boolean; }; export type InternalReplyMarkup = | { kind: 'inline'; rows: InternalInlineButton[][]; } | { kind: 'reply'; rows: InternalReplyButton[][]; resizeKeyboard?: boolean; oneTimeKeyboard?: boolean; selective?: boolean; inputFieldPlaceholder?: string; } | { kind: 'remove'; selective?: boolean; }; export type ParsedReplyMarkup = { internal: InternalReplyMarkup | null; telegram: Record | null; }; export type StoredReplyMarkupPayload = { replyMarkup: InternalReplyMarkup | null; telegramReplyMarkup: Record | null; }; export type TelegramMessageObject = { message_id: number; from: { id: number; is_bot: boolean; first_name: string; username?: string; }; chat: { id: number; type: string; first_name?: string; username?: string; }; date: number; edit_date?: number; text?: string; caption?: string; photo?: Array<{ file_id: string; width: number; height: number }>; document?: { file_id: string; file_name?: string }; reply_markup?: Record; }; export type BotInboundInternalMessage = { updateId: number; botId: string; senderUserId: string; senderDisplayName: string; senderUsername?: string | null; telegramChatId: number; telegramMessageId: number; text?: string | null; createdAt: Date; }; export type BotCallbackInternalEvent = { updateId: number; botId: string; callbackQueryId: string; senderUserId: string; senderDisplayName: string; senderUsername?: string | null; telegramChatId: number; callbackData: string; sourceMessage: TelegramMessageObject; createdAt: Date; }; export type TelegramUpdate = | { update_id: number; message: TelegramMessageObject; } | { update_id: number; callback_query: { id: string; from: { id: number; is_bot: boolean; first_name: string; username?: string; }; message: TelegramMessageObject; chat_instance: string; data?: string; }; }; type StoredBotMessage = { telegramMsgId: number; text?: string | null; messageType?: string | null; mediaUrl?: string | null; createdAt: Date; editedAt?: Date | null; payload?: unknown; }; @Injectable() export class TelegramSerializerService { private readonly logger = new Logger(TelegramSerializerService.name); parseReplyMarkup(replyMarkup: unknown): ParsedReplyMarkup { const markup = this.coerceReplyMarkupInput(replyMarkup); if (!markup) { return { internal: null, telegram: null }; } this.logger.debug(`Parsed Reply Markup: ${JSON.stringify(markup)}`); if (markup.remove_keyboard === true) { const internal: InternalReplyMarkup = { kind: 'remove', selective: markup.selective === true }; return { internal, telegram: { remove_keyboard: true, ...(markup.selective ? { selective: true } : {}) } }; } if (Array.isArray(markup.inline_keyboard)) { const rows = (markup.inline_keyboard as unknown[]).map((row) => Array.isArray(row) ? row .map((button) => this.parseInlineButton(button)) .filter((button): button is InternalInlineButton => Boolean(button)) : [] ); const telegram = { inline_keyboard: this.toTelegramInlineKeyboard(rows) }; return { internal: { kind: 'inline', rows }, telegram }; } if (Array.isArray(markup.keyboard)) { const rows = (markup.keyboard as unknown[]).map((row) => Array.isArray(row) ? row .map((button) => this.parseReplyButton(button)) .filter((button): button is InternalReplyButton => Boolean(button)) : [] ); const internal: InternalReplyMarkup = { kind: 'reply', rows, resizeKeyboard: markup.resize_keyboard === true, oneTimeKeyboard: markup.one_time_keyboard === true, selective: markup.selective === true, inputFieldPlaceholder: typeof markup.input_field_placeholder === 'string' ? markup.input_field_placeholder : undefined }; const telegram: Record = { keyboard: rows.map((row) => row.map((button) => ({ text: button.text, ...(button.requestContact ? { request_contact: true } : {}), ...(button.requestLocation ? { request_location: true } : {}) })) ) }; if (internal.kind === 'reply') { if (internal.resizeKeyboard) telegram.resize_keyboard = true; if (internal.oneTimeKeyboard) telegram.one_time_keyboard = true; if (internal.selective) telegram.selective = true; if (internal.inputFieldPlaceholder) telegram.input_field_placeholder = internal.inputFieldPlaceholder; } return { internal, telegram }; } return { internal: null, telegram: null }; } /** * node-telegram-bot-api и другие клиенты часто передают reply_markup как JSON-строку * (особенно при application/x-www-form-urlencoded). Приводим к объекту Telegram API. */ private coerceReplyMarkupInput(replyMarkup: unknown): Record | null { let current: unknown = replyMarkup; for (let depth = 0; depth < 3; depth += 1) { if (current === null || current === undefined || current === '') { return null; } if (typeof current === 'string') { const trimmed = current.trim(); if (!trimmed) return null; try { current = JSON.parse(trimmed) as unknown; continue; } catch { this.logger.warn('Не удалось распарсить reply_markup как JSON-строку'); return null; } } if (typeof current === 'object' && !Array.isArray(current)) { return this.normalizeReplyMarkupObject(current as Record); } return null; } this.logger.warn('reply_markup содержит слишком глубокую JSON-вложенность'); return null; } private normalizeReplyMarkupObject(markup: Record): Record { const normalized: Record = { ...markup }; if (typeof normalized.inline_keyboard === 'string') { try { normalized.inline_keyboard = JSON.parse(normalized.inline_keyboard.trim()) as unknown; } catch { this.logger.warn('Не удалось распарсить inline_keyboard внутри reply_markup'); delete normalized.inline_keyboard; } } if (typeof normalized.keyboard === 'string') { try { normalized.keyboard = JSON.parse(normalized.keyboard.trim()) as unknown; } catch { this.logger.warn('Не удалось распарсить keyboard внутри reply_markup'); delete normalized.keyboard; } } return normalized; } toTelegramUpdate(internalMessage: BotInboundInternalMessage): TelegramUpdate { return { update_id: internalMessage.updateId, message: this.buildTextMessage({ messageId: internalMessage.telegramMessageId, chatId: internalMessage.telegramChatId, chatType: 'private', from: this.buildUserFrom(internalMessage), date: internalMessage.createdAt, text: internalMessage.text ?? undefined }) }; } toTelegramCallbackUpdate(event: BotCallbackInternalEvent): TelegramUpdate { const firstName = event.senderDisplayName.trim() || 'User'; const username = event.senderUsername?.trim() || undefined; return { update_id: event.updateId, callback_query: { id: event.callbackQueryId, from: { id: event.telegramChatId, is_bot: false, first_name: firstName, ...(username ? { username } : {}) }, message: event.sourceMessage, chat_instance: `${event.botId}:${event.senderUserId}`, data: event.callbackData } }; } buildBotMessageObject(input: { message: StoredBotMessage; bot: { id: string; name: string; username: string }; chat: { telegramChatId: bigint | number; chatType: string; recipientDisplayName: string; recipientUsername?: string | null }; }): TelegramMessageObject { const chatId = Number(input.chat.telegramChatId); const payload = this.readPayload(input.message.payload); const replyMarkup = payload.telegramReplyMarkup ?? undefined; const base = { message_id: input.message.telegramMsgId, from: { id: Number(deriveTelegramChatId(input.bot.id)), is_bot: true, first_name: input.bot.name, username: input.bot.username }, chat: { id: chatId, type: input.chat.chatType, first_name: input.chat.recipientDisplayName, ...(input.chat.recipientUsername ? { username: input.chat.recipientUsername } : {}) }, date: Math.floor(input.message.createdAt.getTime() / 1000), ...(input.message.editedAt ? { edit_date: Math.floor(input.message.editedAt.getTime() / 1000) } : {}), ...(replyMarkup ? { reply_markup: replyMarkup } : {}) }; const messageType = input.message.messageType ?? 'text'; if (messageType === 'photo' && input.message.mediaUrl) { return { ...base, photo: [{ file_id: input.message.mediaUrl, width: 320, height: 240 }], ...(input.message.text ? { caption: input.message.text } : {}) }; } if (messageType === 'document' && input.message.mediaUrl) { return { ...base, document: { file_id: input.message.mediaUrl, file_name: this.extractFileName(input.message.mediaUrl) }, ...(input.message.text ? { caption: input.message.text } : {}) }; } return { ...base, ...(input.message.text ? { text: input.message.text } : {}) }; } buildTextMessage(input: { messageId: number; chatId: number; chatType: string; from: { id: number; displayName: string; username?: string | null; isBot?: boolean }; date: Date; text?: string; editDate?: Date | null; replyMarkup?: Record | null; }): TelegramMessageObject { return { message_id: input.messageId, from: { id: input.from.id, is_bot: input.from.isBot ?? false, first_name: input.from.displayName, ...(input.from.username ? { username: input.from.username } : {}) }, chat: { id: input.chatId, type: input.chatType, first_name: input.from.displayName, ...(input.from.username ? { username: input.from.username } : {}) }, date: Math.floor(input.date.getTime() / 1000), ...(input.editDate ? { edit_date: Math.floor(input.editDate.getTime() / 1000) } : {}), ...(input.text ? { text: input.text } : {}), ...(input.replyMarkup ? { reply_markup: input.replyMarkup } : {}) }; } readPayload(payload: unknown): StoredReplyMarkupPayload { if (!payload || typeof payload !== 'object') { return { replyMarkup: null, telegramReplyMarkup: null }; } const record = payload as { replyMarkup?: InternalReplyMarkup | null; telegramReplyMarkup?: Record | null; reply_markup?: unknown; }; if (!record.telegramReplyMarkup && record.reply_markup) { const parsed = this.parseReplyMarkup(record.reply_markup); return { replyMarkup: parsed.internal, telegramReplyMarkup: parsed.telegram }; } return { replyMarkup: record.replyMarkup ?? null, telegramReplyMarkup: record.telegramReplyMarkup ?? null }; } readPayloadAsParsed(payload: unknown): ParsedReplyMarkup { const stored = this.readPayload(payload); return { internal: stored.replyMarkup, telegram: stored.telegramReplyMarkup }; } buildStoredPayload(parsed: ParsedReplyMarkup) { if (!parsed.internal && !parsed.telegram) return undefined; return { replyMarkup: parsed.internal, telegramReplyMarkup: parsed.telegram }; } private buildUserFrom(input: { telegramChatId: number; senderDisplayName: string; senderUsername?: string | null; }) { const firstName = input.senderDisplayName.trim() || 'User'; const username = input.senderUsername?.trim() || undefined; return { id: input.telegramChatId, displayName: firstName, username, isBot: false }; } private parseInlineButton(raw: unknown): InternalInlineButton | null { if (!raw || typeof raw !== 'object') return null; const button = raw as Record; const text = typeof button.text === 'string' ? button.text.trim() : ''; if (!text) return null; const webApp = button.web_app as { url?: string } | undefined; return { text, callbackData: typeof button.callback_data === 'string' ? button.callback_data : undefined, url: typeof button.url === 'string' ? button.url : undefined, webAppUrl: typeof webApp?.url === 'string' ? webApp.url : undefined }; } private parseReplyButton(raw: unknown): InternalReplyButton | null { if (!raw || typeof raw !== 'object') return null; const button = raw as Record; const text = typeof button.text === 'string' ? button.text.trim() : ''; if (!text) return null; return { text, requestContact: button.request_contact === true, requestLocation: button.request_location === true }; } private toTelegramInlineKeyboard(rows: InternalInlineButton[][]) { return rows.map((row) => row.map((button) => ({ text: button.text, ...(button.callbackData ? { callback_data: button.callbackData } : {}), ...(button.url ? { url: button.url } : {}), ...(button.webAppUrl ? { web_app: { url: button.webAppUrl } } : {}) })) ); } private extractFileName(url: string) { try { const parsed = new URL(url); const segments = parsed.pathname.split('/').filter(Boolean); return segments[segments.length - 1] || 'document'; } catch { return 'document'; } } }