import { All, Body, Controller, Get, HttpCode, Param, Post, Query, Req, Res } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import { firstValueFrom, timeout } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; type HttpRequest = { method: string; query: Record; }; type HttpResponse = { status(code: number): HttpResponse; setHeader(name: string, value: string): void; }; const botPathParam = { name: 'botPath', description: 'Путь токена в формате bot, совместимо с Telegram Bot API.', example: 'bot123456:ABC-DEF' }; const chatIdProperty = { oneOf: [{ type: 'string' }, { type: 'number' }], description: 'ID пользователя/чата в приложении или numeric chat_id из ответа Bot API.' }; @ApiTags('Telegram Bot API') @Controller() export class TelegramBotApiController { constructor(private readonly core: CoreGrpcService) {} @Get(':botPath/getMe') @ApiOperation({ summary: 'Bot API: getMe', description: 'Возвращает профиль бота в формате Telegram Bot API.' }) @ApiParam(botPathParam) getMe(@Res({ passthrough: true }) response: HttpResponse, @Param('botPath') botPath: string) { return this.executeTelegramMethod(response, botPath, 'getMe', {}); } @Post(':botPath/getChat') @HttpCode(200) @ApiOperation({ summary: 'Bot API: getChat', description: 'Возвращает приватный чат приложения и pinned_message, если сообщение закреплено.' }) @ApiParam(botPathParam) @ApiBody({ schema: { type: 'object', required: ['chat_id'], properties: { chat_id: chatIdProperty } } }) getChatPost( @Res({ passthrough: true }) response: HttpResponse, @Param('botPath') botPath: string, @Body() body: Record ) { return this.executeTelegramMethod(response, botPath, 'getChat', body ?? {}); } @Get(':botPath/getChat') @ApiOperation({ summary: 'Bot API: getChat (GET)', description: 'GET-вариант метода getChat для клиентов, которые передают параметры в query.' }) @ApiParam(botPathParam) getChatGet( @Res({ passthrough: true }) response: HttpResponse, @Param('botPath') botPath: string, @Query() query: Record ) { return this.executeTelegramMethod(response, botPath, 'getChat', query ?? {}); } @Post(':botPath/sendMessage') @HttpCode(200) @ApiOperation({ summary: 'Bot API: sendMessage', description: 'Отправляет сообщение пользователю внутри приложения. Поддерживаются text, parse_mode и reply_markup.' }) @ApiParam(botPathParam) @ApiBody({ schema: { type: 'object', required: ['chat_id', 'text'], properties: { chat_id: chatIdProperty, text: { type: 'string' }, parse_mode: { type: 'string', example: 'HTML' }, reply_markup: { type: 'object', additionalProperties: true } } } }) sendMessage( @Res({ passthrough: true }) response: HttpResponse, @Param('botPath') botPath: string, @Body() body: Record ) { return this.executeTelegramMethod(response, botPath, 'sendMessage', body ?? {}); } @Post(':botPath/editMessageText') @HttpCode(200) @ApiOperation({ summary: 'Bot API: editMessageText', description: 'Редактирует текст ранее отправленного ботом сообщения.' }) @ApiParam(botPathParam) @ApiBody({ schema: { type: 'object', required: ['chat_id', 'message_id', 'text'], properties: { chat_id: chatIdProperty, message_id: { type: 'integer' }, text: { type: 'string' }, parse_mode: { type: 'string', example: 'HTML' }, reply_markup: { type: 'object', additionalProperties: true } } } }) editMessageText( @Res({ passthrough: true }) response: HttpResponse, @Param('botPath') botPath: string, @Body() body: Record ) { return this.executeTelegramMethod(response, botPath, 'editMessageText', body ?? {}); } @Post(':botPath/pinChatMessage') @HttpCode(200) @ApiOperation({ summary: 'Bot API: pinChatMessage', description: 'Закрепляет сообщение бота в чате приложения.' }) @ApiParam(botPathParam) @ApiBody({ schema: { type: 'object', required: ['chat_id', 'message_id'], properties: { chat_id: chatIdProperty, message_id: { type: 'integer' }, disable_notification: { type: 'boolean', default: false } } } }) pinChatMessage( @Res({ passthrough: true }) response: HttpResponse, @Param('botPath') botPath: string, @Body() body: Record ) { return this.executeTelegramMethod(response, botPath, 'pinChatMessage', body ?? {}); } @Post(':botPath/unpinChatMessage') @HttpCode(200) @ApiOperation({ summary: 'Bot API: unpinChatMessage', description: 'Открепляет указанное сообщение. Если message_id не передан, открепляет последнее закреплённое сообщение.' }) @ApiParam(botPathParam) @ApiBody({ schema: { type: 'object', required: ['chat_id'], properties: { chat_id: chatIdProperty, message_id: { type: 'integer' } } } }) unpinChatMessage( @Res({ passthrough: true }) response: HttpResponse, @Param('botPath') botPath: string, @Body() body: Record ) { return this.executeTelegramMethod(response, botPath, 'unpinChatMessage', body ?? {}); } @All(':botPath/:method') @HttpCode(200) @ApiOperation({ summary: 'Bot API: универсальный метод', description: 'Совместимый endpoint /bot/. Поддерживаются getMe, getChat, sendMessage, editMessageText, editMessageReplyMarkup, sendPhoto, sendDocument, pinChatMessage, unpinChatMessage, answerCallbackQuery, setWebhook, deleteWebhook, getWebhookInfo, getUpdates и setChatMenuButton.' }) @ApiParam(botPathParam) @ApiParam({ name: 'method', description: 'Название метода Telegram Bot API.', example: 'sendMessage' }) async handleTelegramMethod( @Req() request: HttpRequest, @Res({ passthrough: true }) response: HttpResponse, @Param('botPath') botPath: string, @Param('method') method: string, @Body() body: Record ) { const payload = request.method === 'GET' ? { ...request.query } : body ?? {}; return this.executeTelegramMethod(response, botPath, method, payload); } private async executeTelegramMethod( response: HttpResponse, botPath: string, method: string, payload: Record ) { if (!botPath.startsWith('bot')) { response.status(404); return { ok: false, error_code: 404, description: 'Not Found' }; } const token = decodeURIComponent(botPath.slice(3)); if (!token) { response.status(401); return { ok: false, error_code: 401, description: 'Unauthorized' }; } const grpcCall = this.core.bot.ExecuteBotMethod({ token, method, payloadJson: JSON.stringify(payload) }); const waitSeconds = method === 'getUpdates' ? Number(payload.timeout ?? 0) : 0; const grpcTimeoutMs = method === 'getUpdates' ? Math.min(Math.max(waitSeconds, 0), 50) * 1000 + 10_000 : 30_000; const result = (await firstValueFrom( grpcCall.pipe(timeout(grpcTimeoutMs)) as typeof grpcCall )) as { responseJson: string; httpStatus: number }; response.status(result.httpStatus ?? 200); response.setHeader('Content-Type', 'application/json'); return JSON.parse(result.responseJson); } }