245 lines
7.9 KiB
TypeScript
245 lines
7.9 KiB
TypeScript
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<string, unknown>;
|
||
};
|
||
|
||
type HttpResponse = {
|
||
status(code: number): HttpResponse;
|
||
setHeader(name: string, value: string): void;
|
||
};
|
||
|
||
const botPathParam = {
|
||
name: 'botPath',
|
||
description: 'Путь токена в формате bot<token>, совместимо с 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<string, unknown>
|
||
) {
|
||
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<string, unknown>
|
||
) {
|
||
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<string, unknown>
|
||
) {
|
||
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<string, unknown>
|
||
) {
|
||
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<string, unknown>
|
||
) {
|
||
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<string, unknown>
|
||
) {
|
||
return this.executeTelegramMethod(response, botPath, 'unpinChatMessage', body ?? {});
|
||
}
|
||
|
||
@All(':botPath/:method')
|
||
@HttpCode(200)
|
||
@ApiOperation({
|
||
summary: 'Bot API: универсальный метод',
|
||
description:
|
||
'Совместимый endpoint /bot<TOKEN>/<method>. Поддерживаются 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<string, unknown>
|
||
) {
|
||
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<string, unknown>
|
||
) {
|
||
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);
|
||
}
|
||
}
|