471 lines
14 KiB
TypeScript
471 lines
14 KiB
TypeScript
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';
|
||
}
|
||
}
|
||
}
|