Files
IdP/apps/sso-core/src/domain/bot/telegram-serializer.service.ts
2026-06-25 23:48:57 +03:00

471 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown> | null;
};
export type StoredReplyMarkupPayload = {
replyMarkup: InternalReplyMarkup | null;
telegramReplyMarkup: Record<string, unknown> | 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<string, unknown>;
};
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<string, unknown> = {
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<string, unknown> | 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<string, unknown>);
}
return null;
}
this.logger.warn('reply_markup содержит слишком глубокую JSON-вложенность');
return null;
}
private normalizeReplyMarkupObject(markup: Record<string, unknown>): Record<string, unknown> {
const normalized: Record<string, unknown> = { ...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<string, unknown> | 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<string, unknown> | 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<string, unknown>;
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<string, unknown>;
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';
}
}
}