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,900 @@
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 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<string, unknown>) {
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 '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 '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<string, unknown> | 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, bot as ValidatedBot, context.chat, message, parsedMarkup.internal);
return message;
}
async listBotChatMessages(userId: string, botRef: 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 chat = await this.prisma.botChat.findUnique({
where: { botId_recipientUserId: { botId: bot.id, recipientUserId: userId } },
include: { menuButton: true }
});
const composer = this.resolveComposerForBot(bot, chat?.menuButton?.menuButton ?? null);
const emptyResponse = {
messages: [] as Array<Record<string, unknown>>,
botUsername: bot.username,
botDisplayName: bot.name,
botId: bot.id,
botOwnerId: bot.ownerId,
composerWebAppUrl: composer.composerWebAppUrl,
composerMenuButtonJson: composer.composerMenuButtonJson,
manageWebAppUrl: buildBotManageWebAppUrl(bot.id)
};
if (!chat) {
return emptyResponse;
}
const [outbound, inbound] = await Promise.all([
this.prisma.botMessage.findMany({
where: { botChatId: chat.id },
orderBy: { createdAt: 'asc' }
}),
this.prisma.botInboundMessage.findMany({
where: { botChatId: chat.id },
orderBy: { createdAt: 'asc' }
})
]);
const messages = [
...inbound.map((item) => ({
id: `in-${item.id}`,
direction: 'in' as const,
text: item.text ?? '',
messageType: 'text',
messageId: item.telegramMsgId,
createdAt: item.createdAt.toISOString(),
replyMarkupJson: null as string | null
})),
...outbound.map((item) => {
const payload = this.serializer.readPayload(item.payload);
const replyMarkup = payload.telegramReplyMarkup ?? payload.replyMarkup;
return {
id: `out-${item.id}`,
direction: 'out' as const,
text: item.text ?? '',
messageType: item.messageType ?? 'text',
messageId: item.telegramMsgId,
createdAt: item.createdAt.toISOString(),
replyMarkupJson: replyMarkup ? JSON.stringify(replyMarkup) : 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 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
);
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, 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, 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, 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, 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, bot, located.chat, updated, parsedMarkup.internal);
return this.wrapOk(this.buildTelegramMessage(bot, located, updated));
}
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<string, unknown>) {
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<string, unknown>;
}
) {
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 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 };
chat: { telegramChatId: bigint; chatType: string };
},
message: {
telegramMsgId: number;
text?: string | null;
messageType?: string | null;
mediaUrl?: string | null;
createdAt: Date;
editedAt?: Date | null;
payload?: unknown;
}
) {
return this.serializer.buildBotMessageObject({
message,
bot,
chat: {
telegramChatId: context.chat.telegramChatId,
chatType: context.chat.chatType,
recipientDisplayName: context.recipient.displayName,
recipientUsername: context.recipient.username
}
});
}
private async publishBotMessage(
userId: string,
bot: ValidatedBot,
chat: { telegramChatId: bigint; chatType: string },
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);
await this.notifications.publishRealtime(userId, 'bot_message', bot.name, message.text ?? '', {
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
});
}
private async publishBotMessageEdited(
userId: string,
bot: ValidatedBot,
chat: { telegramChatId: bigint; chatType: string },
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);
await this.notifications.publishRealtime(userId, 'bot_message_edited', bot.name, message.text ?? '', {
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()
});
}
private mergePayload(existing: unknown, parsed: ReturnType<TelegramSerializerService['parseReplyMarkup']>) {
const current = existing && typeof existing === 'object' ? (existing as Record<string, unknown>) : {};
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))
};
}
}

View File

@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { RedisService } from '../../infra/redis.service';
export type RegisteredCallbackQuery = {
userId: string;
botId: string;
callbackData: string;
messageId: number;
chatId: number;
};
@Injectable()
export class BotCallbackRegistryService {
constructor(private readonly redis: RedisService) {}
private key(callbackQueryId: string) {
return `bot:callback:${callbackQueryId}`;
}
async register(callbackQueryId: string, payload: RegisteredCallbackQuery) {
await this.redis.client.set(this.key(callbackQueryId), JSON.stringify(payload), 'EX', 3600);
}
async resolve(callbackQueryId: string) {
const raw = await this.redis.client.get(this.key(callbackQueryId));
if (!raw) return null;
try {
return JSON.parse(raw) as RegisteredCallbackQuery;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,9 @@
export function formatTokenForLog(value: string, chunkSize = 48) {
const normalized = value.trim();
if (!normalized) return '(empty)';
const chunks: string[] = [];
for (let index = 0; index < normalized.length; index += chunkSize) {
chunks.push(normalized.slice(index, index + chunkSize));
}
return chunks.join('\n');
}

View File

@@ -0,0 +1,163 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../infra/prisma.service';
import { BotCallbackRegistryService } from './bot-callback-registry.service';
import { BotInboundQueueMessage } from './bot-inbound-publisher.service';
import { BotUpdateQueueService } from './bot-update-queue.service';
import { BotWebhookDispatcherService } from './bot-webhook-dispatcher.service';
import { TelegramSerializerService } from './telegram-serializer.service';
import { isProtectedSystemAccount } from '../system-account.util';
@Injectable()
export class BotDeliveryService {
constructor(
private readonly prisma: PrismaService,
private readonly serializer: TelegramSerializerService,
private readonly webhookDispatcher: BotWebhookDispatcherService,
private readonly updateQueue: BotUpdateQueueService,
private readonly callbackRegistry: BotCallbackRegistryService
) {}
async deliverInboundEvent(event: BotInboundQueueMessage) {
if ((event.eventType ?? 'message') === 'callback_query') {
await this.deliverCallbackQuery(event);
return;
}
await this.deliverMessage(event);
}
private async deliverMessage(event: BotInboundQueueMessage) {
const [bot, sender] = await Promise.all([
this.loadBot(event.botId),
this.loadSender(event.senderUserId)
]);
if (!bot || !sender) return;
const internalMessage = {
updateId: event.updateId,
botId: event.botId,
senderUserId: sender.id,
senderDisplayName: sender.displayName,
senderUsername: sender.username,
telegramChatId: Number(event.telegramChatId),
telegramMessageId: event.telegramMessageId,
text: event.text,
createdAt: new Date(event.createdAt)
};
const update = this.serializer.toTelegramUpdate(internalMessage);
await this.persistAndDispatch(bot, update);
}
private async deliverCallbackQuery(event: BotInboundQueueMessage) {
const [bot, sender, sourceMessage] = await Promise.all([
this.loadBot(event.botId),
this.loadSender(event.senderUserId),
event.sourceBotMessageId
? this.prisma.botMessage.findFirst({
where: {
botId: event.botId,
botChatId: event.botChatId,
telegramMsgId: event.sourceBotMessageId
},
include: {
botChat: {
include: {
bot: true
}
}
}
})
: Promise.resolve(null)
]);
if (!bot || !sender || !sourceMessage || !event.callbackQueryId || !event.callbackData) return;
const recipient = await this.prisma.user.findUnique({
where: { id: sender.id },
select: { displayName: true, username: true }
});
if (!recipient) return;
const telegramMessage = this.serializer.buildBotMessageObject({
message: sourceMessage,
bot: sourceMessage.botChat.bot,
chat: {
telegramChatId: sourceMessage.botChat.telegramChatId,
chatType: sourceMessage.botChat.chatType,
recipientDisplayName: recipient.displayName,
recipientUsername: recipient.username
}
});
const callbackQueryId = event.callbackQueryId;
await this.callbackRegistry.register(callbackQueryId, {
userId: sender.id,
botId: bot.id,
callbackData: event.callbackData,
messageId: sourceMessage.telegramMsgId,
chatId: Number(event.telegramChatId)
});
const update = this.serializer.toTelegramCallbackUpdate({
updateId: event.updateId,
botId: event.botId,
callbackQueryId,
senderUserId: sender.id,
senderDisplayName: sender.displayName,
senderUsername: sender.username,
telegramChatId: Number(event.telegramChatId),
callbackData: event.callbackData,
sourceMessage: telegramMessage,
createdAt: new Date(event.createdAt)
});
await this.persistAndDispatch(bot, update);
}
private async persistAndDispatch(
bot: { id: string; webhookUrl: string | null; webhookSecretToken: string | null },
update: ReturnType<TelegramSerializerService['toTelegramUpdate']> | ReturnType<TelegramSerializerService['toTelegramCallbackUpdate']>
) {
await this.prisma.botUpdate.upsert({
where: { botId_updateId: { botId: bot.id, updateId: update.update_id } },
create: {
botId: bot.id,
updateId: update.update_id,
payload: update as object
},
update: {
payload: update as object
}
});
if (bot.webhookUrl) {
await this.webhookDispatcher.dispatch(bot.webhookUrl, update, bot.webhookSecretToken);
return;
}
await this.updateQueue.enqueue(bot.id, update);
}
private loadBot(botId: string) {
return this.prisma.bot.findUnique({
where: { id: botId },
select: {
id: true,
isActive: true,
webhookUrl: true,
webhookSecretToken: true
}
}).then((bot) => (bot?.isActive ? bot : null));
}
private loadSender(senderUserId: string) {
return this.prisma.user.findUnique({
where: { id: senderUserId },
select: { id: true, displayName: true, username: true, deletedAt: true, status: true, isSystemAccount: true, linkedBotId: true }
}).then((sender) =>
sender && !sender.deletedAt && sender.status === 'ACTIVE' && !isProtectedSystemAccount(sender) ? sender : null
);
}
}

View File

@@ -0,0 +1,203 @@
import { Injectable, Logger } from '@nestjs/common';
import { BotApiService } from './bot-api.service';
import { BOTFATHER_BOT_USERNAME, buildBotCreateWebAppUrl, buildBotManageWebAppUrl } from './bot-father.constants';
import { BotFatherSeedService } from './bot-father-seed.service';
import { BotFatherService } from './bot-father.service';
@Injectable()
export class BotFatherAssistantService {
private readonly logger = new Logger(BotFatherAssistantService.name);
constructor(
private readonly seed: BotFatherSeedService,
private readonly botFather: BotFatherService,
private readonly botApi: BotApiService
) {}
async handleUserMessage(senderUserId: string, text: string) {
const botId = await this.seed.getBotFatherBotId();
const trimmed = text.trim();
const lower = trimmed.toLowerCase();
if (lower === '/start' || lower === '/help') {
await this.botApi.sendInternalBotMessage(botId, senderUserId, this.buildWelcomeText(), {
inline_keyboard: [
[{ text: '🤖 Создать бота', web_app: { url: this.botCreateMiniAppUrl() } }],
[{ text: '📋 Мои боты', callback_data: 'mybots' }]
]
});
return;
}
if (lower.startsWith('/newbot')) {
await this.handleNewBotCommand(senderUserId, trimmed, botId);
return;
}
if (lower === '/mybots') {
await this.handleMyBots(senderUserId, botId);
return;
}
const profileCommand = this.parseProfileCommand(trimmed);
if (profileCommand) {
await this.handleProfileCommand(senderUserId, botId, profileCommand);
return;
}
await this.botApi.sendInternalBotMessage(
botId,
senderUserId,
'Неизвестная команда. Отправьте /help для списка команд или /newbot Название username для создания бота.'
);
}
async handleCallbackQuery(senderUserId: string, callbackData: string) {
const botId = await this.seed.getBotFatherBotId();
if (callbackData === 'newbot_help' || callbackData === 'newbot') {
await this.botApi.sendInternalBotMessage(
botId,
senderUserId,
'Откройте Mini App «Создать бота» — пошаговый мастер как в Telegram BotFather.\n\nИли отправьте:\n/newbot Название username',
{ inline_keyboard: [[{ text: '🤖 Создать бота', web_app: { url: this.botCreateMiniAppUrl() } }]] }
);
return;
}
if (callbackData === 'mybots') {
await this.handleMyBots(senderUserId, botId);
}
}
private async handleMyBots(senderUserId: string, botId: string) {
const { bots, total } = await this.botFather.listMyBots(senderUserId);
const list = bots.length
? bots.map((bot) => `• @${bot.username}${bot.name}`).join('\n')
: 'У вас пока нет ботов.';
const keyboard = bots.map((bot) => [
{ text: `⚙️ ${bot.name}`, web_app: { url: buildBotManageWebAppUrl(bot.id) } }
]);
await this.botApi.sendInternalBotMessage(
botId,
senderUserId,
`Ваши боты (${total}):\n\n${list}${bots.length ? '\n\nНажмите кнопку ниже, чтобы открыть настройки бота.' : ''}`,
keyboard.length ? { inline_keyboard: keyboard } : undefined
);
}
private parseProfileCommand(text: string):
| { kind: 'setdescription' | 'setabouttext' | 'setuserpic' | 'setmenubutton'; botUsername: string; value: string }
| null {
const match = text.match(/^\/(setdescription|setabouttext|setuserpic|setmenubutton)\s+@?(\S+)\s+([\s\S]+)$/i);
if (!match) {
return null;
}
return {
kind: match[1]!.toLowerCase() as 'setdescription' | 'setabouttext' | 'setuserpic' | 'setmenubutton',
botUsername: match[2]!,
value: match[3]!.trim()
};
}
private async handleProfileCommand(
senderUserId: string,
botId: string,
command: { kind: 'setdescription' | 'setabouttext' | 'setuserpic' | 'setmenubutton'; botUsername: string; value: string }
) {
try {
if (command.kind === 'setdescription') {
await this.botFather.updateBotProfileByOwner(senderUserId, command.botUsername, { description: command.value });
await this.botApi.sendInternalBotMessage(
botId,
senderUserId,
`✅ Описание бота @${command.botUsername.replace(/_bot$/i, '')}_bot обновлено.`
);
return;
}
if (command.kind === 'setabouttext') {
await this.botFather.updateBotProfileByOwner(senderUserId, command.botUsername, { aboutText: command.value });
await this.botApi.sendInternalBotMessage(
botId,
senderUserId,
`✅ Краткое описание бота @${command.botUsername.replace(/_bot$/i, '')}_bot обновлено.`
);
return;
}
if (command.kind === 'setuserpic') {
await this.botFather.updateBotProfileByOwner(senderUserId, command.botUsername, { botPicUrl: command.value });
await this.botApi.sendInternalBotMessage(
botId,
senderUserId,
`✅ Аватар бота @${command.botUsername.replace(/_bot$/i, '')}_bot обновлён.`
);
return;
}
await this.botFather.updateBotProfileByOwner(senderUserId, command.botUsername, { menuButton: command.value });
await this.botApi.sendInternalBotMessage(
botId,
senderUserId,
`✅ Глобальная кнопка меню бота @${command.botUsername.replace(/_bot$/i, '')}_bot обновлена.`
);
} catch (error) {
const message = error instanceof Error ? error.message : 'Не удалось обновить профиль бота';
this.logger.warn(`BotFather profile command error for ${senderUserId}: ${message}`);
await this.botApi.sendInternalBotMessage(botId, senderUserId, `${message}`);
}
}
private botCreateMiniAppUrl() {
return buildBotCreateWebAppUrl();
}
private async handleNewBotCommand(senderUserId: string, text: string, botId: string) {
const parts = text.split(/\s+/);
if (parts.length < 3) {
await this.botApi.sendInternalBotMessage(
botId,
senderUserId,
'Создайте бота через Mini App — это быстрее и удобнее, чем вводить команду вручную.',
{ inline_keyboard: [[{ text: '🤖 Создать бота', web_app: { url: this.botCreateMiniAppUrl() } }]] }
);
return;
}
const username = parts[parts.length - 1]!;
const name = parts.slice(1, -1).join(' ').trim();
if (!name) {
await this.botApi.sendInternalBotMessage(botId, senderUserId, 'Укажите название бота перед username.');
return;
}
try {
const result = await this.botFather.createBot(senderUserId, name, username);
await this.botApi.sendInternalBotMessage(
botId,
senderUserId,
`✅ Бот @${result.bot.username} создан!\n\nСохраните токен — он больше не будет показан:\n${result.token}\n\nУправление: раздел «Боты» или REST /bots`
);
} catch (error) {
const message = error instanceof Error ? error.message : 'Не удалось создать бота';
this.logger.warn(`BotFather createBot error for ${senderUserId}: ${message}`);
await this.botApi.sendInternalBotMessage(botId, senderUserId, `${message}`);
}
}
private buildWelcomeText() {
return [
`Привет! Я ${BOTFATHER_BOT_USERNAME.replace(/_bot$/, '')} — помогу создать Telegram-бота в Lendry ID.`,
'',
'Команды:',
'/newbot — создать бота (Mini App или команда с названием и username)',
'/mybots — список ваших ботов',
'/setdescription username текст — описание перед /start',
'/setabouttext username текст — краткое описание в профиле',
'/setuserpic username URL — аватар бота',
'/setmenubutton username JSON|URL — глобальная кнопка меню (Web App)',
'/help — эта справка',
'',
'Username указывайте без суффикса _bot — он добавится автоматически.'
].join('\n');
}
}

View File

@@ -0,0 +1,138 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../../infra/prisma.service';
import {
BOTFATHER_BOT_USERNAME,
BOTFATHER_DISPLAY_NAME,
BOTFATHER_SETTING_BOT_ID,
BOTFATHER_SETTING_USER_ID,
BOTFATHER_USER_USERNAME,
SYSTEM_BOT_OWNER_EMAIL
} from './bot-father.constants';
import { generateTelegramStyleBotToken, hashBotToken } from './bot-token.util';
@Injectable()
export class BotFatherSeedService implements OnModuleInit {
private readonly logger = new Logger(BotFatherSeedService.name);
private cachedUserId: string | null = null;
private cachedBotId: string | null = null;
constructor(private readonly prisma: PrismaService) {}
async onModuleInit() {
await this.ensureBotFather();
}
async getBotFatherUserId() {
if (this.cachedUserId) return this.cachedUserId;
const setting = await this.prisma.systemSetting.findUnique({ where: { key: BOTFATHER_SETTING_USER_ID } });
if (setting?.value) {
this.cachedUserId = setting.value;
return setting.value;
}
const ensured = await this.ensureBotFather();
return ensured.userId;
}
async getBotFatherBotId() {
if (this.cachedBotId) return this.cachedBotId;
const setting = await this.prisma.systemSetting.findUnique({ where: { key: BOTFATHER_SETTING_BOT_ID } });
if (setting?.value) {
this.cachedBotId = setting.value;
return setting.value;
}
const ensured = await this.ensureBotFather();
return ensured.botId;
}
async ensureBotFather() {
const existingBotId = await this.prisma.systemSetting.findUnique({ where: { key: BOTFATHER_SETTING_BOT_ID } });
const existingUserId = await this.prisma.systemSetting.findUnique({ where: { key: BOTFATHER_SETTING_USER_ID } });
if (existingBotId?.value && existingUserId?.value) {
this.cachedBotId = existingBotId.value;
this.cachedUserId = existingUserId.value;
return { userId: existingUserId.value, botId: existingBotId.value };
}
const ownerId = await this.resolveBotOwnerId();
let botFatherUser = await this.prisma.user.findFirst({
where: { username: BOTFATHER_USER_USERNAME, isSystemAccount: true }
});
if (!botFatherUser) {
botFatherUser = await this.prisma.user.create({
data: {
displayName: BOTFATHER_DISPLAY_NAME,
username: BOTFATHER_USER_USERNAME,
isSystemAccount: true,
isVerified: true,
verificationIcon: 'bot'
}
});
this.logger.log(`Создан системный пользователь ${BOTFATHER_DISPLAY_NAME}`);
}
let bot = await this.prisma.bot.findUnique({ where: { username: BOTFATHER_BOT_USERNAME } });
if (!bot) {
const { token, tokenPrefix } = generateTelegramStyleBotToken();
bot = await this.prisma.bot.create({
data: {
name: BOTFATHER_DISPLAY_NAME,
username: BOTFATHER_BOT_USERNAME,
tokenHash: hashBotToken(token),
tokenPrefix,
ownerId,
isSystemBot: true
}
});
this.logger.log(`Создан системный бот @${BOTFATHER_BOT_USERNAME}`);
} else if (!bot.isSystemBot) {
bot = await this.prisma.bot.update({
where: { id: bot.id },
data: { isSystemBot: true, isActive: true }
});
}
if (botFatherUser.linkedBotId !== bot.id) {
await this.prisma.user.update({
where: { id: botFatherUser.id },
data: { linkedBotId: bot.id }
});
}
await this.upsertSetting(BOTFATHER_SETTING_USER_ID, botFatherUser.id, 'UUID системного пользователя BotFather');
await this.upsertSetting(BOTFATHER_SETTING_BOT_ID, bot.id, 'UUID системного бота BotFather');
this.cachedUserId = botFatherUser.id;
this.cachedBotId = bot.id;
return { userId: botFatherUser.id, botId: bot.id };
}
private async resolveBotOwnerId() {
const superAdmin = await this.prisma.user.findFirst({
where: { isSuperAdmin: true, deletedAt: null, status: 'ACTIVE', isSystemAccount: false },
orderBy: { createdAt: 'asc' },
select: { id: true }
});
if (superAdmin) return superAdmin.id;
const systemOwner = await this.prisma.user.findUnique({ where: { email: SYSTEM_BOT_OWNER_EMAIL } });
if (systemOwner) return systemOwner.id;
const created = await this.prisma.user.create({
data: {
email: SYSTEM_BOT_OWNER_EMAIL,
displayName: 'System Bot Owner',
isSystemAccount: true
}
});
return created.id;
}
private async upsertSetting(key: string, value: string, description: string) {
await this.prisma.systemSetting.upsert({
where: { key },
create: { key, value, description },
update: { value, description }
});
}
}

View File

@@ -0,0 +1,23 @@
export const BOTFATHER_BOT_USERNAME = 'BotFather_bot';
export const BOTFATHER_USER_USERNAME = 'BotFather';
export const BOTFATHER_DISPLAY_NAME = 'BotFather';
export const BOTFATHER_SETTING_USER_ID = 'BOTFATHER_USER_ID';
export const BOTFATHER_SETTING_BOT_ID = 'BOTFATHER_BOT_ID';
export const SYSTEM_BOT_OWNER_EMAIL = 'system-bot-owner@internal.lendry.id';
export function resolvePublicFrontendUrl() {
return (process.env.PUBLIC_FRONTEND_URL ?? 'http://localhost:3002').replace(/\/$/, '');
}
export function buildBotManageWebAppUrl(botId: string) {
return `${resolvePublicFrontendUrl()}/mini-apps/bot-manage?botId=${botId}`;
}
export function buildBotCreateWebAppUrl() {
return `${resolvePublicFrontendUrl()}/mini-apps/bot-create`;
}
export function isBotManageWebAppUrl(url: string | null | undefined) {
if (!url?.trim()) return false;
return url.includes('/mini-apps/bot-manage');
}

View File

@@ -0,0 +1,591 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
OnModuleInit
} from '@nestjs/common';
import { PrismaService } from '../../infra/prisma.service';
import { AccessService } from '../access.service';
import { NotificationsService } from '../notifications.service';
import { SettingsService } from '../settings.service';
import { BotRateLimitService } from './bot-rate-limit.service';
import { assertValidBotUsername, generateTelegramStyleBotToken, hashBotToken, normalizeBotUsername } from './bot-token.util';
import { menuButtonToJson, parseMenuButtonInput, resolveComposerMenuButton } from './bot-menu-button.util';
import { assertNotReservedUsername, isProtectedSystemAccount } from '../system-account.util';
type BotRecord = {
id: string;
name: string;
username: string;
tokenHash: string;
tokenPrefix: string;
ownerId: string;
description?: string | null;
aboutText?: string | null;
botPicUrl?: string | null;
menuButton?: unknown;
webAppUrl: string | null;
isActive: boolean;
isSystemBot: boolean;
createdAt: Date;
updatedAt: Date;
owner?: { id: string; displayName: string; username: string | null };
_count?: { messages: number; chats: number };
};
@Injectable()
export class BotFatherService implements OnModuleInit {
private readonly logger = new Logger(BotFatherService.name);
constructor(
private readonly prisma: PrismaService,
private readonly access: AccessService,
private readonly settings: SettingsService,
private readonly rateLimit: BotRateLimitService,
private readonly notifications: NotificationsService
) {}
async onModuleInit() {
await this.backfillBotSystemUsers();
}
async ensureBotSystemUser(botId: string) {
const bot = await this.prisma.bot.findUnique({
where: { id: botId },
include: { linkedSystemUser: { select: { id: true } } }
});
if (!bot) {
throw new NotFoundException('Бот не найден');
}
if (bot.linkedSystemUser) {
return { userId: bot.linkedSystemUser.id, botId: bot.id };
}
const existing = await this.prisma.user.findFirst({
where: { linkedBotId: bot.id },
select: { id: true }
});
if (existing) {
return { userId: existing.id, botId: bot.id };
}
const systemUser = await this.prisma.user.create({
data: {
displayName: bot.name,
isSystemAccount: true,
isVerified: true,
verificationIcon: 'bot',
linkedBotId: bot.id
},
select: { id: true }
});
this.logger.log(`Создан системный пользователь для бота @${bot.username}`);
return { userId: systemUser.id, botId: bot.id };
}
private async backfillBotSystemUsers() {
const bots = await this.prisma.bot.findMany({
where: { linkedSystemUser: null },
select: { id: true, username: true }
});
for (const bot of bots) {
try {
await this.ensureBotSystemUser(bot.id);
} catch (error) {
this.logger.warn(`Не удалось создать системного пользователя для @${bot.username}: ${String(error)}`);
}
}
}
async createBot(ownerId: string, name: string, username: string) {
const trimmedName = name.trim();
if (!trimmedName) {
throw new BadRequestException('Укажите название бота');
}
let normalizedUsername: string;
try {
normalizedUsername = assertValidBotUsername(username);
} catch {
throw new BadRequestException('Username бота должен содержать 532 символа: латиница, цифры и _, без суффикса _bot');
}
assertNotReservedUsername(normalizedUsername.replace(/_bot$/, ''));
const owner = await this.prisma.user.findUnique({ where: { id: ownerId } });
if (!owner || owner.deletedAt || owner.status !== 'ACTIVE') {
throw new NotFoundException('Владелец бота не найден');
}
if (isProtectedSystemAccount(owner)) {
throw new BadRequestException('Системные учётные записи не могут владеть ботами');
}
const maxBots = await this.settings.getNumber('BOT_MAX_BOTS_PER_USER', 5);
const ownedCount = await this.prisma.bot.count({ where: { ownerId } });
if (ownedCount >= maxBots) {
throw new BadRequestException(`Достигнут лимит ботов на пользователя (${maxBots})`);
}
const limitCheck = await this.rateLimit.assertCanCreateBot(ownerId);
if (!limitCheck.allowed) {
throw new BadRequestException(limitCheck.reason);
}
const { token, tokenPrefix } = generateTelegramStyleBotToken();
const tokenHash = hashBotToken(token);
try {
const bot = await this.prisma.bot.create({
data: {
name: trimmedName,
username: normalizedUsername,
tokenHash,
tokenPrefix,
ownerId
},
include: {
owner: { select: { id: true, displayName: true, username: true } },
_count: { select: { messages: true, chats: true } }
}
});
await this.ensureBotSystemUser(bot.id);
await this.rateLimit.syncOwnerBotCount(ownerId, ownedCount + 1);
return { bot: this.toBotResponse(bot), token };
} catch (error) {
if (this.isUniqueViolation(error)) {
throw new ConflictException('Бот с таким username уже существует');
}
throw error;
}
}
async revokeToken(requesterId: string, botId: string, isSuperAdmin: boolean) {
const bot = await this.getManagedBot(requesterId, botId, isSuperAdmin);
const { token, tokenPrefix } = generateTelegramStyleBotToken();
const tokenHash = hashBotToken(token);
const updated = await this.prisma.bot.update({
where: { id: bot.id },
data: { tokenHash, tokenPrefix },
include: {
owner: { select: { id: true, displayName: true, username: true } },
_count: { select: { messages: true, chats: true } }
}
});
await this.rateLimit.invalidateTokenCache(bot.tokenHash);
return { bot: this.toBotResponse(updated), token };
}
async setWebApp(requesterId: string, botId: string, webAppUrl: string | null | undefined, isSuperAdmin: boolean) {
const bot = await this.getManagedBot(requesterId, botId, isSuperAdmin);
const normalizedUrl = webAppUrl?.trim() || null;
if (normalizedUrl && !/^https?:\/\//i.test(normalizedUrl)) {
throw new BadRequestException('Mini App URL должен начинаться с http:// или https://');
}
const updated = await this.prisma.bot.update({
where: { id: bot.id },
data: { webAppUrl: normalizedUrl },
include: {
owner: { select: { id: true, displayName: true, username: true } },
_count: { select: { messages: true, chats: true } }
}
});
return this.toBotResponse(updated);
}
async listMyBots(ownerId: string) {
const bots = await this.prisma.bot.findMany({
where: { ownerId },
orderBy: { createdAt: 'desc' },
include: {
owner: { select: { id: true, displayName: true, username: true } },
_count: { select: { messages: true, chats: true } }
}
});
return { bots: bots.map((bot) => this.toBotResponse(bot)), total: bots.length };
}
async getBot(requesterId: string, botId: string, isSuperAdmin: boolean) {
const bot = await this.getManagedBot(requesterId, botId, isSuperAdmin, true);
return this.toBotResponse(bot);
}
async updateBot(
requesterId: string,
botId: string,
payload: { name?: string; username?: string },
isSuperAdmin: boolean
) {
const bot = await this.getManagedBot(requesterId, botId, isSuperAdmin);
const data: { name?: string; username?: string } = {};
if (payload.name !== undefined) {
const trimmedName = payload.name.trim();
if (!trimmedName) {
throw new BadRequestException('Укажите название бота');
}
data.name = trimmedName;
}
if (payload.username !== undefined) {
try {
data.username = assertValidBotUsername(payload.username);
} catch {
throw new BadRequestException('Username бота должен содержать 532 символа: латиница, цифры и _, без суффикса _bot');
}
if (normalizeBotUsername(payload.username) === normalizeBotUsername(bot.username.replace(/_bot$/, ''))) {
delete data.username;
}
}
try {
const updated = await this.prisma.bot.update({
where: { id: bot.id },
data,
include: {
owner: { select: { id: true, displayName: true, username: true } },
_count: { select: { messages: true, chats: true } }
}
});
return this.toBotResponse(updated);
} catch (error) {
if (this.isUniqueViolation(error)) {
throw new ConflictException('Бот с таким username уже существует');
}
throw error;
}
}
async deleteBot(requesterId: string, botId: string, isSuperAdmin: boolean) {
const bot = await this.getManagedBot(requesterId, botId, isSuperAdmin);
await this.prisma.bot.delete({ where: { id: bot.id } });
await this.rateLimit.invalidateTokenCache(bot.tokenHash);
const ownedCount = await this.prisma.bot.count({ where: { ownerId: bot.ownerId } });
await this.rateLimit.syncOwnerBotCount(bot.ownerId, ownedCount);
return { count: 1 };
}
async listAllBots(requesterId: string, isSuperAdmin: boolean, search?: string, page = 1, limit = 20) {
await this.assertManageAllBots(requesterId, isSuperAdmin);
const safePage = Math.max(page, 1);
const safeLimit = Math.min(Math.max(limit, 1), 100);
const where = search?.trim()
? {
OR: [
{ name: { contains: search.trim(), mode: 'insensitive' as const } },
{ username: { contains: normalizeBotUsername(search.trim()), mode: 'insensitive' as const } }
]
}
: {};
const [bots, total] = await Promise.all([
this.prisma.bot.findMany({
where,
skip: (safePage - 1) * safeLimit,
take: safeLimit,
orderBy: { createdAt: 'desc' },
include: {
owner: { select: { id: true, displayName: true, username: true } },
_count: { select: { messages: true, chats: true } }
}
}),
this.prisma.bot.count({ where })
]);
return { bots: bots.map((bot) => this.toBotResponse(bot)), total };
}
async updateBotProfileByOwner(
ownerId: string,
botUsernameRaw: string,
patch: {
description?: string | null;
aboutText?: string | null;
botPicUrl?: string | null;
menuButton?: unknown;
}
) {
const bot = await this.findOwnedBotByUsername(ownerId, botUsernameRaw);
return this.applyBotProfilePatch(bot.id, bot.tokenHash, bot.ownerId, patch);
}
async updateBotProfile(
requesterId: string,
botId: string,
patch: {
description?: string | null;
aboutText?: string | null;
botPicUrl?: string | null;
menuButton?: unknown;
},
isSuperAdmin: boolean
) {
const bot = await this.getManagedBot(requesterId, botId, isSuperAdmin);
return this.applyBotProfilePatch(bot.id, bot.tokenHash, bot.ownerId, patch);
}
private async applyBotProfilePatch(
botId: string,
tokenHash: string,
ownerId: string,
patch: {
description?: string | null;
aboutText?: string | null;
botPicUrl?: string | null;
menuButton?: unknown;
}
) {
const data: {
description?: string | null;
aboutText?: string | null;
botPicUrl?: string | null;
menuButton?: unknown;
} = {};
if (patch.description !== undefined) {
const value = patch.description?.trim() || null;
if (value && value.length > 512) {
throw new BadRequestException('Описание бота не может быть длиннее 512 символов');
}
data.description = value;
}
if (patch.aboutText !== undefined) {
const value = patch.aboutText?.trim() || null;
if (value && value.length > 120) {
throw new BadRequestException('Краткое описание не может быть длиннее 120 символов');
}
data.aboutText = value;
}
if (patch.botPicUrl !== undefined) {
const value = patch.botPicUrl?.trim() || null;
if (value && !/^https?:\/\//i.test(value)) {
throw new BadRequestException('URL аватара должен начинаться с http:// или https://');
}
data.botPicUrl = value;
}
if (patch.menuButton !== undefined) {
const parsed = parseMenuButtonInput(patch.menuButton);
data.menuButton = menuButtonToJson(parsed ?? null);
}
const updated = await this.prisma.bot.update({
where: { id: botId },
data: data as never,
include: {
owner: { select: { id: true, displayName: true, username: true } },
_count: { select: { messages: true, chats: true } }
}
});
await this.rateLimit.invalidateTokenCache(tokenHash);
await this.publishBotProfileUpdated(ownerId, updated as BotRecord);
if (patch.menuButton !== undefined) {
await this.broadcastGlobalMenuButtonChange(updated as BotRecord);
}
return this.toBotResponse(updated as BotRecord);
}
async setBotActive(requesterId: string, botId: string, isActive: boolean, isSuperAdmin: boolean) {
await this.assertManageAllBots(requesterId, isSuperAdmin);
const bot = await this.prisma.bot.findUnique({ where: { id: botId } });
if (!bot) {
throw new NotFoundException('Бот не найден');
}
const updated = await this.prisma.bot.update({
where: { id: botId },
data: { isActive },
include: {
owner: { select: { id: true, displayName: true, username: true } },
_count: { select: { messages: true, chats: true } }
}
});
await this.rateLimit.invalidateTokenCache(updated.tokenHash);
return this.toBotResponse(updated);
}
async getBotMetrics(requesterId: string, isSuperAdmin: boolean) {
await this.assertManageAllBots(requesterId, isSuperAdmin);
const [totalBots, activeBots, blockedBots, totalMessages, totalChats] = await Promise.all([
this.prisma.bot.count(),
this.prisma.bot.count({ where: { isActive: true } }),
this.prisma.bot.count({ where: { isActive: false } }),
this.prisma.botMessage.count(),
this.prisma.botChat.count()
]);
return { totalBots, activeBots, blockedBots, totalMessages, totalChats };
}
async validateBotToken(token: string) {
const tokenHash = hashBotToken(token);
const cached = await this.rateLimit.getCachedValidatedBot(tokenHash);
if (cached) {
return cached;
}
const bot = await this.prisma.bot.findUnique({
where: { tokenHash },
select: {
id: true,
name: true,
username: true,
ownerId: true,
webAppUrl: true,
menuButton: true,
webhookUrl: true,
webhookSecretToken: true,
isActive: true,
isSystemBot: true
}
});
if (!bot || !bot.isActive) {
return null;
}
const payload = {
id: bot.id,
name: bot.name,
username: bot.username,
ownerId: bot.ownerId,
webAppUrl: bot.webAppUrl,
menuButton: bot.menuButton,
webhookUrl: bot.webhookUrl,
webhookSecretToken: bot.webhookSecretToken,
isActive: bot.isActive,
isSystemBot: bot.isSystemBot
};
await this.rateLimit.cacheValidatedBot(tokenHash, payload);
return payload;
}
async invalidateBotCacheById(botId: string) {
const bot = await this.prisma.bot.findUnique({ where: { id: botId }, select: { tokenHash: true } });
if (bot) {
await this.rateLimit.invalidateTokenCache(bot.tokenHash);
}
}
private async getManagedBot(requesterId: string, botId: string, isSuperAdmin: boolean, includeCounts = false) {
const bot = await this.prisma.bot.findUnique({
where: { id: botId },
include: {
owner: { select: { id: true, displayName: true, username: true } },
...(includeCounts ? { _count: { select: { messages: true, chats: true } } } : {})
}
});
if (!bot) {
throw new NotFoundException('Бот не найден');
}
if (!isSuperAdmin && bot.ownerId !== requesterId) {
throw new ForbiddenException('Недостаточно прав для управления этим ботом');
}
return bot as BotRecord;
}
private async assertManageAllBots(requesterId: string, isSuperAdmin: boolean) {
if (isSuperAdmin) return;
try {
await this.access.assertPermission(requesterId, false, 'bots.manage.all');
} catch {
throw new ForbiddenException('Недостаточно прав для управления всеми ботами');
}
}
private toBotResponse(bot: BotRecord) {
return {
id: bot.id,
name: bot.name,
username: bot.username,
tokenPrefix: bot.tokenPrefix,
ownerId: bot.ownerId,
description: bot.description ?? undefined,
aboutText: bot.aboutText ?? undefined,
botPicUrl: bot.botPicUrl ?? undefined,
menuButtonJson: bot.menuButton ? JSON.stringify(bot.menuButton) : undefined,
webAppUrl: bot.webAppUrl ?? undefined,
isActive: bot.isActive,
isSystemBot: bot.isSystemBot,
createdAt: bot.createdAt.toISOString(),
updatedAt: bot.updatedAt.toISOString(),
owner: bot.owner
? {
id: bot.owner.id,
displayName: bot.owner.displayName,
username: bot.owner.username ?? undefined
}
: undefined,
messageCount: bot._count?.messages ?? 0,
chatCount: bot._count?.chats ?? 0
};
}
private async findOwnedBotByUsername(ownerId: string, botUsernameRaw: string) {
const normalized = normalizeBotUsername(botUsernameRaw.replace(/^@/, ''));
const bot = await this.prisma.bot.findFirst({
where: {
ownerId,
OR: [{ username: normalized }, { username: `${normalized.replace(/_bot$/i, '')}_bot` }]
}
});
if (!bot) {
throw new NotFoundException('Бот не найден или не принадлежит вам');
}
return bot;
}
private async publishBotProfileUpdated(ownerId: string, bot: BotRecord) {
await this.notifications.publishRealtime(ownerId, 'bot_profile_updated', bot.name, '', {
botId: bot.id,
botUsername: bot.username,
description: bot.description ?? null,
aboutText: bot.aboutText ?? null,
botPicUrl: bot.botPicUrl ?? null,
menuButtonJson: bot.menuButton ? JSON.stringify(bot.menuButton) : null
});
}
private async broadcastGlobalMenuButtonChange(bot: BotRecord) {
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 resolved = resolveComposerMenuButton({
chatOverride: chat.menuButton?.menuButton ?? null,
globalMenuButton: bot.menuButton,
bot
});
await this.notifications.publishRealtime(chat.recipientUserId, 'bot_menu_button_updated', bot.name, '', {
botId: bot.id,
botUsername: bot.username,
composerWebAppUrl: resolved.composerWebAppUrl ?? null,
composerMenuButtonJson: resolved.menuButton ? JSON.stringify(resolved.menuButton) : null,
buttonText: resolved.buttonText ?? null
});
})
);
}
private isUniqueViolation(error: unknown) {
return typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'P2002';
}
}

View File

@@ -0,0 +1,78 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import amqp from 'amqplib';
export const BOT_INBOUND_QUEUE = 'chat.message.bot_inbound';
export interface BotInboundQueueMessage {
eventType: 'message' | 'callback_query';
botId: string;
senderUserId: string;
text?: string | null;
botChatId: string;
telegramChatId: string;
telegramMessageId: number;
updateId: number;
createdAt: string;
callbackQueryId?: string;
callbackData?: string;
sourceBotMessageId?: number;
}
@Injectable()
export class BotInboundPublisherService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(BotInboundPublisherService.name);
private connection: { createChannel: () => Promise<amqp.Channel>; close: () => Promise<void> } | null = null;
private channel: amqp.Channel | null = null;
constructor(private readonly config: ConfigService) {}
async onModuleInit() {
await this.connect();
}
async onModuleDestroy() {
try {
await this.channel?.close();
} catch {
// ignore
}
try {
await this.connection?.close();
} catch {
// ignore
}
}
private async connect() {
const url = this.config.get<string>('RABBITMQ_URL', 'amqp://lendry:lendry_password@localhost:5672/');
try {
this.connection = (await amqp.connect(url)) as {
createChannel: () => Promise<amqp.Channel>;
close: () => Promise<void>;
};
this.channel = await this.connection.createChannel();
await this.channel.assertQueue(BOT_INBOUND_QUEUE, { durable: true });
this.logger.log(`Очередь ${BOT_INBOUND_QUEUE} готова к публикации`);
} catch (error) {
this.logger.warn(
`RabbitMQ недоступен для bot inbound: ${error instanceof Error ? error.message : error}`
);
}
}
async publish(message: BotInboundQueueMessage) {
if (!this.channel) {
await this.connect();
}
if (!this.channel) return false;
try {
this.channel.sendToQueue(BOT_INBOUND_QUEUE, Buffer.from(JSON.stringify(message)), { persistent: true });
return true;
} catch (error) {
this.logger.warn(`Не удалось опубликовать bot inbound: ${error instanceof Error ? error.message : error}`);
return false;
}
}
}

View File

@@ -0,0 +1,213 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../infra/prisma.service';
import { BotDeliveryService } from './bot-delivery.service';
import { BotFatherAssistantService } from './bot-father-assistant.service';
import { BotFatherSeedService } from './bot-father-seed.service';
import { BotInboundPublisherService } from './bot-inbound-publisher.service';
import { deriveTelegramChatId, normalizeBotUsername } from './bot-token.util';
import { assertHumanAccount } from '../system-account.util';
@Injectable()
export class BotInboundService {
constructor(
private readonly prisma: PrismaService,
private readonly publisher: BotInboundPublisherService,
private readonly delivery: BotDeliveryService,
private readonly seed: BotFatherSeedService,
private readonly assistant: BotFatherAssistantService
) {}
async submitUserMessage(senderUserId: string, botRef: string, text: string) {
await this.assertHumanSender(senderUserId);
const trimmedText = text.trim();
if (!trimmedText) {
throw new BadRequestException('Текст сообщения не может быть пустым');
}
const bot = await this.findBot(botRef);
if (!bot || !bot.isActive) {
throw new NotFoundException('Бот не найден или заблокирован');
}
const chat = await this.ensureBotChat(bot.id, senderUserId);
const event = await this.createMessageEvent(bot.id, chat.id, senderUserId, chat.telegramChatId, trimmedText);
await this.dispatchEvent(event);
if (bot.isSystemBot) {
void this.assistant.handleUserMessage(senderUserId, trimmedText).catch(() => undefined);
}
return {
updateId: event.updateId,
messageId: event.telegramMessageId,
chatId: event.telegramChatId
};
}
async submitCallbackQuery(senderUserId: string, botRef: string, messageId: number, callbackData: string) {
await this.assertHumanSender(senderUserId);
const trimmedData = callbackData.trim();
if (!trimmedData) {
throw new BadRequestException('callbackData не может быть пустым');
}
if (!Number.isFinite(messageId) || messageId <= 0) {
throw new BadRequestException('messageId некорректен');
}
const bot = await this.findBot(botRef);
if (!bot || !bot.isActive) {
throw new NotFoundException('Бот не найден или заблокирован');
}
const chat = await this.ensureBotChat(bot.id, senderUserId);
const sourceMessage = await this.prisma.botMessage.findFirst({
where: {
botId: bot.id,
botChatId: chat.id,
telegramMsgId: messageId
}
});
if (!sourceMessage) {
throw new NotFoundException('Сообщение бота для callback не найдено');
}
const updateId = await this.allocateUpdateId(bot.id);
const event = {
eventType: 'callback_query' as const,
botId: bot.id,
senderUserId,
botChatId: chat.id,
telegramChatId: chat.telegramChatId.toString(),
telegramMessageId: messageId,
updateId,
createdAt: new Date().toISOString(),
callbackQueryId: randomUUID(),
callbackData: trimmedData,
sourceBotMessageId: messageId
};
await this.dispatchEvent(event);
if (bot.isSystemBot) {
void this.assistant.handleCallbackQuery(senderUserId, trimmedData).catch(() => undefined);
}
return {
updateId: event.updateId,
callbackQueryId: event.callbackQueryId,
messageId: event.telegramMessageId
};
}
private async createMessageEvent(
botId: string,
botChatId: string,
senderUserId: string,
telegramChatId: bigint,
text: string
) {
return this.prisma.$transaction(async (tx) => {
const [updatedBot, updatedChat] = await Promise.all([
tx.bot.update({
where: { id: botId },
data: { nextUpdateId: { increment: 1 } },
select: { nextUpdateId: true }
}),
tx.botChat.update({
where: { id: botChatId },
data: { nextInboundMessageId: { increment: 1 } },
select: { nextInboundMessageId: true }
})
]);
const updateId = updatedBot.nextUpdateId - 1;
const telegramMessageId = updatedChat.nextInboundMessageId - 1;
await tx.botInboundMessage.create({
data: {
botId,
botChatId,
senderUserId,
telegramMsgId: telegramMessageId,
text
}
});
return {
eventType: 'message' as const,
botId,
senderUserId,
text,
botChatId,
telegramChatId: telegramChatId.toString(),
telegramMessageId,
updateId,
createdAt: new Date().toISOString()
};
});
}
private async allocateUpdateId(botId: string) {
const updated = await this.prisma.bot.update({
where: { id: botId },
data: { nextUpdateId: { increment: 1 } },
select: { nextUpdateId: true }
});
return updated.nextUpdateId - 1;
}
private async dispatchEvent(event: Parameters<BotInboundPublisherService['publish']>[0]) {
const published = await this.publisher.publish(event);
if (!published) {
await this.delivery.deliverInboundEvent(event);
}
}
private async assertHumanSender(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { isSystemAccount: true, linkedBotId: true, deletedAt: true, status: true }
});
if (!user || user.deletedAt || user.status !== 'ACTIVE') {
throw new NotFoundException('Отправитель не найден');
}
assertHumanAccount(user, { message: 'Системные учётные записи не могут взаимодействовать с ботами' });
}
private async findBot(botRef: string) {
const normalized = normalizeBotUsername(botRef.replace(/_bot$/i, ''));
return this.prisma.bot.findFirst({
where: {
OR: [{ id: botRef }, { username: botRef }, { username: `${normalized}_bot` }, { username: normalized }]
},
select: { id: true, isActive: true, isSystemBot: true }
});
}
private async ensureBotChat(botId: string, recipientUserId: string) {
const existing = await this.prisma.botChat.findUnique({
where: { botId_recipientUserId: { botId, recipientUserId } }
});
if (existing) return existing;
const telegramChatId = 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 NotFoundException('Не удалось создать чат с ботом');
}
return fallback;
}
}
}

View File

@@ -0,0 +1,162 @@
import { BOTFATHER_BOT_USERNAME, buildBotCreateWebAppUrl, isBotManageWebAppUrl } from './bot-father.constants';
export type TelegramMenuButtonDefault = { type: 'default' };
export type TelegramMenuButtonCommands = { type: 'commands' };
export type TelegramMenuButtonWebApp = {
type: 'web_app';
text: string;
web_app: { url: string };
};
export type TelegramMenuButton =
| TelegramMenuButtonDefault
| TelegramMenuButtonCommands
| TelegramMenuButtonWebApp;
export type ResolvedComposerMenuButton = {
menuButton: TelegramMenuButtonWebApp | null;
composerWebAppUrl?: string;
buttonText?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function coerceJsonValue(raw: unknown): unknown {
if (typeof raw !== 'string') {
return raw;
}
const trimmed = raw.trim();
if (!trimmed) {
return raw;
}
try {
return JSON.parse(trimmed);
} catch {
return raw;
}
}
function normalizeWebAppButton(value: Record<string, unknown>): TelegramMenuButtonWebApp | null {
const text = typeof value.text === 'string' ? value.text.trim() : '';
const webApp = isRecord(value.web_app) ? value.web_app : null;
const url = typeof webApp?.url === 'string' ? webApp.url.trim() : '';
if (!text || !url || !/^https?:\/\//i.test(url)) {
return null;
}
return { type: 'web_app', text, web_app: { url } };
}
export function parseMenuButtonInput(raw: unknown): TelegramMenuButton | null | undefined {
if (raw === undefined) {
return undefined;
}
if (raw === null) {
return null;
}
const coerced = coerceJsonValue(raw);
if (typeof coerced === 'string') {
const trimmed = coerced.trim();
if (!trimmed) {
return null;
}
if (/^https?:\/\//i.test(trimmed)) {
return { type: 'web_app', text: 'App', web_app: { url: trimmed } };
}
const pipeParts = trimmed.split('|');
if (pipeParts.length === 2 && /^https?:\/\//i.test(pipeParts[0]!.trim())) {
return {
type: 'web_app',
text: pipeParts[1]!.trim() || 'App',
web_app: { url: pipeParts[0]!.trim() }
};
}
return null;
}
if (!isRecord(coerced)) {
return null;
}
const type = typeof coerced.type === 'string' ? coerced.type.trim().toLowerCase() : '';
if (!type || type === 'default') {
return { type: 'default' };
}
if (type === 'commands') {
return { type: 'commands' };
}
if (type === 'web_app') {
return normalizeWebAppButton(coerced);
}
return null;
}
export function menuButtonToJson(menuButton: TelegramMenuButton | null | undefined): Record<string, unknown> | null {
if (!menuButton || menuButton.type === 'default') {
return null;
}
if (menuButton.type === 'commands') {
return { type: 'commands' };
}
return {
type: 'web_app',
text: menuButton.text,
web_app: { url: menuButton.web_app.url }
};
}
export function extractWebAppUrl(menuButton: unknown): string | undefined {
const parsed = parseMenuButtonInput(menuButton);
if (!parsed || parsed.type !== 'web_app') {
return undefined;
}
const url = parsed.web_app.url.trim();
if (!url || isBotManageWebAppUrl(url)) {
return undefined;
}
return url;
}
export function resolveComposerMenuButton(options: {
chatOverride?: unknown;
globalMenuButton?: unknown;
bot: { isSystemBot: boolean; username: string; webAppUrl?: string | null };
}): ResolvedComposerMenuButton {
const chatParsed = parseMenuButtonInput(options.chatOverride);
if (chatParsed && chatParsed.type === 'web_app') {
const url = extractWebAppUrl(chatParsed);
if (url) {
return { menuButton: chatParsed, composerWebAppUrl: url, buttonText: chatParsed.text };
}
}
const globalParsed = parseMenuButtonInput(options.globalMenuButton);
if (globalParsed && globalParsed.type === 'web_app') {
const url = extractWebAppUrl(globalParsed);
if (url) {
return { menuButton: globalParsed, composerWebAppUrl: url, buttonText: globalParsed.text };
}
}
if (options.bot.isSystemBot || options.bot.username === BOTFATHER_BOT_USERNAME) {
const url = buildBotCreateWebAppUrl();
return {
menuButton: { type: 'web_app', text: 'Создать бота', web_app: { url } },
composerWebAppUrl: url,
buttonText: 'Создать бота'
};
}
const legacyUrl = options.bot.webAppUrl?.trim();
if (legacyUrl && !isBotManageWebAppUrl(legacyUrl)) {
return {
menuButton: { type: 'web_app', text: 'App', web_app: { url: legacyUrl } },
composerWebAppUrl: legacyUrl,
buttonText: 'App'
};
}
return { menuButton: null };
}

View File

@@ -0,0 +1,78 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import amqp from 'amqplib';
import { BOT_INBOUND_QUEUE, BotInboundQueueMessage } from './bot-inbound-publisher.service';
import { BotDeliveryService } from './bot-delivery.service';
@Injectable()
export class BotMessageConsumerService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(BotMessageConsumerService.name);
private connection: { createChannel: () => Promise<amqp.Channel>; close: () => Promise<void> } | null = null;
private channel: amqp.Channel | null = null;
private consumerTag: string | null = null;
constructor(
private readonly config: ConfigService,
private readonly delivery: BotDeliveryService
) {}
async onModuleInit() {
await this.connect();
}
async onModuleDestroy() {
try {
if (this.channel && this.consumerTag) {
await this.channel.cancel(this.consumerTag);
}
await this.channel?.close();
} catch {
// ignore
}
try {
await this.connection?.close();
} catch {
// ignore
}
}
private async connect() {
const url = this.config.get<string>('RABBITMQ_URL', 'amqp://lendry:lendry_password@localhost:5672/');
try {
this.connection = (await amqp.connect(url)) as {
createChannel: () => Promise<amqp.Channel>;
close: () => Promise<void>;
};
this.channel = await this.connection.createChannel();
await this.channel.assertQueue(BOT_INBOUND_QUEUE, { durable: true });
await this.channel.prefetch(10);
const consumeResult = await this.channel.consume(BOT_INBOUND_QUEUE, (message) => {
if (!message) return;
void this.handleMessage(message);
});
this.consumerTag = consumeResult.consumerTag;
this.logger.log(`Consumer ${BOT_INBOUND_QUEUE} запущен`);
} catch (error) {
this.logger.warn(
`Не удалось запустить consumer ${BOT_INBOUND_QUEUE}: ${error instanceof Error ? error.message : error}`
);
}
}
private async handleMessage(message: amqp.Message) {
if (!this.channel) return;
try {
const payload = JSON.parse(message.content.toString()) as BotInboundQueueMessage;
await this.delivery.deliverInboundEvent(payload);
this.channel.ack(message);
} catch (error) {
this.logger.error(
`Ошибка обработки bot inbound: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined
);
this.channel.nack(message, false, false);
}
}
}

View File

@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { RedisService } from '../../infra/redis.service';
import { SettingsService } from '../settings.service';
@Injectable()
export class BotRateLimitService {
constructor(
private readonly redis: RedisService,
private readonly settings: SettingsService
) {}
async assertCanCreateBot(ownerId: string) {
const maxBots = await this.settings.getNumber('BOT_MAX_BOTS_PER_USER', 5);
const key = `bot:create:count:${ownerId}`;
const cached = await this.redis.client.get(key);
if (cached) {
const count = Number(cached);
if (Number.isFinite(count) && count >= maxBots) {
return { allowed: false as const, reason: `Достигнут лимит ботов (${maxBots})` };
}
}
return { allowed: true as const, maxBots };
}
async syncOwnerBotCount(ownerId: string, count: number) {
await this.redis.client.set(`bot:create:count:${ownerId}`, String(count), 'EX', 300);
}
async assertBotApiRateLimit(tokenHash: string) {
const limit = await this.settings.getNumber('BOT_API_RATE_LIMIT_PER_SECOND', 30);
const key = `bot:api:rate:${tokenHash}`;
const count = await this.redis.client.incr(key);
if (count === 1) {
await this.redis.client.expire(key, 1);
}
if (count > limit) {
const retryAfter = Math.max(await this.redis.client.ttl(key), 1);
return { allowed: false as const, retryAfter };
}
return { allowed: true as const };
}
async cacheValidatedBot(tokenHash: string, payload: Record<string, unknown>) {
await this.redis.client.set(`bot:token:${tokenHash}`, JSON.stringify(payload), 'EX', 300);
}
async getCachedValidatedBot(tokenHash: string) {
const cached = await this.redis.client.get(`bot:token:${tokenHash}`);
return cached ? (JSON.parse(cached) as Record<string, unknown>) : null;
}
async invalidateTokenCache(tokenHash: string) {
await this.redis.client.del(`bot:token:${tokenHash}`);
}
}

View File

@@ -0,0 +1,44 @@
import { createHash, randomBytes } from 'node:crypto';
const BOT_USERNAME_PATTERN = /^[a-z][a-z0-9_]{4,31}$/;
export function normalizeBotUsername(username: string) {
return username.trim().toLowerCase().replace(/[^a-z0-9_]/g, '');
}
export function assertValidBotUsername(username: string) {
const normalized = normalizeBotUsername(username);
if (!BOT_USERNAME_PATTERN.test(normalized)) {
throw new Error('INVALID_BOT_USERNAME');
}
if (normalized.endsWith('_bot')) {
throw new Error('INVALID_BOT_USERNAME');
}
return `${normalized}_bot`;
}
export function generateTelegramStyleBotToken() {
const botId = randomBytes(4).readUInt32BE(0) % 900_000_000 + 100_000_000;
const secret = randomBytes(32)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
const token = `${botId}:${secret}`;
return { token, tokenPrefix: `${botId}:${secret.slice(0, 6)}` };
}
export function hashBotToken(token: string) {
return createHash('sha256').update(token.trim()).digest('hex');
}
export function deriveTelegramChatId(source: string) {
const hash = createHash('sha256').update(source).digest();
const value = hash.readBigInt64BE(0);
const positive = value < 0n ? -value : value;
return positive % 9_000_000_000_000n + 1_000_000_000_000n;
}
export function deriveTelegramUserId(userId: string) {
return Number(deriveTelegramChatId(`user:${userId}`));
}

View File

@@ -0,0 +1,146 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { EventEmitter } from 'node:events';
import { RedisService } from '../../infra/redis.service';
export type StoredTelegramUpdate = Record<string, unknown> & { update_id: number };
@Injectable()
export class BotUpdateQueueService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(BotUpdateQueueService.name);
private readonly emitter = new EventEmitter();
private subscriberReady = false;
constructor(private readonly redis: RedisService) {}
async onModuleInit() {
try {
const subscriber = this.redis.client.duplicate();
await subscriber.connect();
await subscriber.psubscribe('bot:updates:notify:*');
subscriber.on('pmessage', (_pattern, channel) => {
const botId = channel.replace('bot:updates:notify:', '');
if (botId) {
this.emitter.emit(this.waitKey(botId));
}
});
this.subscriberReady = true;
} catch (error) {
this.logger.warn(
`Redis pub/sub для long polling недоступен: ${error instanceof Error ? error.message : error}`
);
}
}
async onModuleDestroy() {
this.emitter.removeAllListeners();
}
private listKey(botId: string) {
return `bot:updates:${botId}`;
}
private waitKey(botId: string) {
return `bot:updates:wait:${botId}`;
}
async enqueue(botId: string, update: StoredTelegramUpdate) {
const serialized = JSON.stringify(update);
await this.redis.client.rpush(this.listKey(botId), serialized);
await this.redis.client.publish(`bot:updates:notify:${botId}`, '1');
this.emitter.emit(this.waitKey(botId));
}
async pendingCount(botId: string) {
return this.redis.client.llen(this.listKey(botId));
}
async clearQueue(botId: string) {
await this.redis.client.del(this.listKey(botId));
}
async acknowledgeBeforeOffset(botId: string, offset: number) {
if (!offset || offset <= 0) return;
const key = this.listKey(botId);
const items = await this.redis.client.lrange(key, 0, -1);
if (!items.length) return;
const remaining = items.filter((item) => {
try {
const parsed = JSON.parse(item) as StoredTelegramUpdate;
return parsed.update_id >= offset;
} catch {
return true;
}
});
const pipeline = this.redis.client.pipeline();
pipeline.del(key);
if (remaining.length) {
pipeline.rpush(key, ...remaining);
}
await pipeline.exec();
}
async fetchUpdates(botId: string, offset: number, limit: number) {
const safeLimit = Math.min(Math.max(limit, 1), 100);
const items = await this.redis.client.lrange(this.listKey(botId), 0, -1);
const parsed = items
.map((item) => {
try {
return JSON.parse(item) as StoredTelegramUpdate;
} catch {
return null;
}
})
.filter((item): item is StoredTelegramUpdate => Boolean(item))
.filter((item) => item.update_id >= offset)
.sort((left, right) => left.update_id - right.update_id)
.slice(0, safeLimit);
return parsed;
}
async getUpdates(botId: string, offset: number, limit: number, timeoutSeconds: number) {
const safeTimeout = Math.min(Math.max(timeoutSeconds, 0), 50);
await this.acknowledgeBeforeOffset(botId, offset);
const immediate = await this.fetchUpdates(botId, offset, limit);
if (immediate.length || safeTimeout <= 0) {
return immediate;
}
return new Promise<StoredTelegramUpdate[]>((resolve) => {
let settled = false;
const finish = async () => {
if (settled) return;
settled = true;
clearTimeout(timer);
this.emitter.off(this.waitKey(botId), onWake);
resolve(await this.fetchUpdates(botId, offset, limit));
};
const onWake = () => {
void finish();
};
const timer = setTimeout(() => {
void finish();
}, safeTimeout * 1000);
this.emitter.once(this.waitKey(botId), onWake);
if (!this.subscriberReady) {
void this.pollUntilUpdates(botId, offset, safeTimeout * 1000).then(() => finish());
}
});
}
private async pollUntilUpdates(botId: string, offset: number, maxWaitMs: number) {
const started = Date.now();
while (Date.now() - started < maxWaitMs) {
const updates = await this.fetchUpdates(botId, offset, 1);
if (updates.length) return;
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}

View File

@@ -0,0 +1,49 @@
import { Injectable, Logger } from '@nestjs/common';
import { SettingsService } from '../settings.service';
import type { TelegramUpdate } from './telegram-serializer.service';
@Injectable()
export class BotWebhookDispatcherService {
private readonly logger = new Logger(BotWebhookDispatcherService.name);
constructor(private readonly settings: SettingsService) {}
async dispatch(webhookUrl: string, update: TelegramUpdate, secretToken?: string | null) {
const timeoutMs = await this.settings.getNumber('BOT_WEBHOOK_TIMEOUT_MS', 10_000);
const maxAttempts = await this.settings.getNumber('BOT_WEBHOOK_MAX_RETRIES', 3);
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(secretToken ? { 'X-Telegram-Bot-Api-Secret-Token': secretToken } : {})
},
body: JSON.stringify(update),
signal: AbortSignal.timeout(timeoutMs)
});
if (response.ok) {
return;
}
lastError = new Error(`Webhook HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
if (attempt < maxAttempts) {
const delayMs = Math.min(1000 * 2 ** (attempt - 1), 8000);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
this.logger.warn(
`Не удалось доставить update ${update.update_id} на webhook ${webhookUrl}: ${
lastError instanceof Error ? lastError.message : lastError
}`
);
}
}

View File

@@ -0,0 +1,19 @@
export type TelegramApiResponse<T = unknown> =
| { ok: true; result: T }
| { ok: false; error_code: number; description: string; parameters?: { retry_after?: number } };
export function telegramOk<T>(result: T): TelegramApiResponse<T> {
return { ok: true, result };
}
export function telegramError(
errorCode: number,
description: string,
parameters?: { retry_after?: number }
): TelegramApiResponse<never> {
return parameters ? { ok: false, error_code: errorCode, description, parameters } : { ok: false, error_code: errorCode, description };
}
export function serializeTelegramResponse(response: TelegramApiResponse) {
return JSON.stringify(response);
}

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';
}
}
}

View File

@@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import { createHmac, timingSafeEqual } from 'node:crypto';
@Injectable()
export class WebAppCryptoService {
validateWebAppData(initData: string, botToken: string, maxAgeSeconds = 86_400) {
if (!initData?.trim() || !botToken?.trim()) {
return { valid: false as const, error: 'Пустые initData или botToken' };
}
const params = new URLSearchParams(initData);
const receivedHash = params.get('hash');
if (!receivedHash) {
return { valid: false as const, error: 'Отсутствует hash в initData' };
}
const authDateRaw = params.get('auth_date');
if (!authDateRaw) {
return { valid: false as const, error: 'Отсутствует auth_date в initData' };
}
const authDate = Number(authDateRaw);
if (!Number.isFinite(authDate)) {
return { valid: false as const, error: 'Некорректный auth_date' };
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (nowSeconds - authDate > maxAgeSeconds) {
return { valid: false as const, error: 'Срок действия initData истёк' };
}
const dataCheckString = [...params.entries()]
.filter(([key]) => key !== 'hash')
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key}=${value}`)
.join('\n');
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest();
const calculatedHash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex');
const receivedBuffer = Buffer.from(receivedHash, 'hex');
const calculatedBuffer = Buffer.from(calculatedHash, 'hex');
if (receivedBuffer.length !== calculatedBuffer.length || !timingSafeEqual(receivedBuffer, calculatedBuffer)) {
return { valid: false as const, error: 'Подпись initData не совпадает' };
}
const userRaw = params.get('user');
return {
valid: true as const,
userJson: userRaw ?? undefined,
authDate: authDateRaw
};
}
}