global update and global fix

This commit is contained in:
lendry
2026-06-25 23:48:57 +03:00
parent 3880c68d59
commit b0ea87e898
121 changed files with 13759 additions and 663 deletions

View File

@@ -0,0 +1,470 @@
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';
}
}
}