global update and global fix
This commit is contained in:
68
apps/api-gateway/src/controllers/admin-bot.controller.ts
Normal file
68
apps/api-gateway/src/controllers/admin-bot.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||
import { ListAdminBotsQueryDto, SetBotActiveDto } from '../dto/bot.dto';
|
||||
import { AdminGuard, AdminRequestUser } from '../guards/admin.guard';
|
||||
|
||||
function assertManageAllBots(admin: AdminRequestUser) {
|
||||
if (admin.isSuperAdmin || admin.permissions.includes('bots.manage.all')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('Недостаточно прав для управления всеми ботами');
|
||||
}
|
||||
|
||||
@ApiTags('Администрирование ботов')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AdminGuard)
|
||||
@Controller('admin/bots')
|
||||
export class AdminBotController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Список всех ботов', description: 'Административный список Telegram-ботов с поиском и пагинацией.' })
|
||||
listAllBots(@Query() query: ListAdminBotsQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertManageAllBots(admin);
|
||||
return firstValueFrom(
|
||||
this.core.bot.ListAllBots({
|
||||
requesterId: admin.id,
|
||||
isSuperAdmin: admin.isSuperAdmin,
|
||||
search: query.search,
|
||||
page: query.page,
|
||||
limit: query.limit
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Get('metrics')
|
||||
@ApiOperation({ summary: 'Метрики ботов', description: 'Сводная статистика по ботам системы.' })
|
||||
getMetrics(@CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertManageAllBots(admin);
|
||||
return firstValueFrom(this.core.bot.GetBotMetrics({ requesterId: admin.id, isSuperAdmin: admin.isSuperAdmin }));
|
||||
}
|
||||
|
||||
@Get(':botId')
|
||||
@ApiOperation({ summary: 'Получить бота (админ)', description: 'Возвращает любого бота по ID.' })
|
||||
getBot(@Param('botId') botId: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertManageAllBots(admin);
|
||||
return firstValueFrom(
|
||||
this.core.bot.GetBot({ requesterId: admin.id, botId, isSuperAdmin: admin.isSuperAdmin })
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':botId/active')
|
||||
@ApiOperation({ summary: 'Заблокировать или разблокировать бота', description: 'Анти-abuse: отключает Bot API токен.' })
|
||||
@ApiBody({ type: SetBotActiveDto })
|
||||
setActive(@Param('botId') botId: string, @Body() dto: SetBotActiveDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||
assertManageAllBots(admin);
|
||||
return firstValueFrom(
|
||||
this.core.bot.SetBotActive({
|
||||
requesterId: admin.id,
|
||||
botId,
|
||||
isActive: dto.isActive,
|
||||
isSuperAdmin: admin.isSuperAdmin
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
159
apps/api-gateway/src/controllers/bot.controller.ts
Normal file
159
apps/api-gateway/src/controllers/bot.controller.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { CreateBotDto, SetBotWebAppDto, SubmitBotCallbackDto, SubmitBotMessageDto, UpdateBotDto, UpdateBotProfileDto, ValidateWebAppInitDataDto } from '../dto/bot.dto';
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
|
||||
@ApiTags('BotFather')
|
||||
@ApiBearerAuth()
|
||||
@Controller('bots')
|
||||
export class BotController {
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
private async auth(authorization?: string) {
|
||||
return getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Список моих ботов', description: 'Возвращает Telegram-ботов текущего пользователя.' })
|
||||
listMyBots(@Headers('authorization') authorization?: string) {
|
||||
return this.auth(authorization).then((userId) => firstValueFrom(this.core.bot.ListMyBots({ ownerId: userId })));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Создать бота', description: 'Регистрирует нового бота и возвращает токен Bot API.' })
|
||||
@ApiBody({ type: CreateBotDto })
|
||||
createBot(@Headers('authorization') authorization: string | undefined, @Body() dto: CreateBotDto) {
|
||||
return this.auth(authorization).then((ownerId) =>
|
||||
firstValueFrom(this.core.bot.CreateBot({ ownerId, name: dto.name, username: dto.username }))
|
||||
);
|
||||
}
|
||||
|
||||
@Get('by-username/:botRef/messages')
|
||||
@ApiOperation({ summary: 'История чата с ботом', description: 'Возвращает сообщения пользователя с ботом в хронологическом порядке.' })
|
||||
listBotMessages(@Headers('authorization') authorization: string | undefined, @Param('botRef') botRef: string) {
|
||||
return this.auth(authorization).then((userId) =>
|
||||
firstValueFrom(this.core.bot.ListBotChatMessages({ userId, botRef }))
|
||||
);
|
||||
}
|
||||
|
||||
@Post('by-username/:botRef/messages')
|
||||
@ApiOperation({
|
||||
summary: 'Написать боту',
|
||||
description: 'Публикует inbound-событие в RabbitMQ для доставки боту через webhook или getUpdates.'
|
||||
})
|
||||
@ApiBody({ type: SubmitBotMessageDto })
|
||||
submitMessage(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('botRef') botRef: string,
|
||||
@Body() dto: SubmitBotMessageDto
|
||||
) {
|
||||
return this.auth(authorization).then((senderUserId) =>
|
||||
firstValueFrom(this.core.bot.SubmitBotInboundMessage({ senderUserId, botRef, text: dto.text }))
|
||||
);
|
||||
}
|
||||
|
||||
@Post('by-username/:botRef/callback')
|
||||
@ApiOperation({
|
||||
summary: 'Нажатие inline-кнопки',
|
||||
description: 'Публикует callback_query Update для webhook или getUpdates.'
|
||||
})
|
||||
@ApiBody({ type: SubmitBotCallbackDto })
|
||||
submitCallback(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('botRef') botRef: string,
|
||||
@Body() dto: SubmitBotCallbackDto
|
||||
) {
|
||||
return this.auth(authorization).then((senderUserId) =>
|
||||
firstValueFrom(
|
||||
this.core.bot.SubmitBotCallbackQuery({
|
||||
senderUserId,
|
||||
botRef,
|
||||
messageId: dto.messageId,
|
||||
callbackData: dto.callbackData
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Post('web-app/validate')
|
||||
@ApiOperation({
|
||||
summary: 'Проверить initData Mini App',
|
||||
description: 'Валидирует Telegram Web App initData по HMAC-SHA256, как в официальном Bot API.'
|
||||
})
|
||||
@ApiBody({ type: ValidateWebAppInitDataDto })
|
||||
validateWebApp(@Body() dto: ValidateWebAppInitDataDto) {
|
||||
return firstValueFrom(this.core.bot.ValidateWebAppInitData(dto));
|
||||
}
|
||||
|
||||
@Get(':botId')
|
||||
@ApiOperation({ summary: 'Получить бота', description: 'Возвращает настройки бота, принадлежащего пользователю.' })
|
||||
getBot(@Headers('authorization') authorization: string | undefined, @Param('botId') botId: string) {
|
||||
return this.auth(authorization).then((requesterId) =>
|
||||
firstValueFrom(this.core.bot.GetBot({ requesterId, botId, isSuperAdmin: false }))
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':botId')
|
||||
@ApiOperation({ summary: 'Обновить бота', description: 'Изменяет имя или username бота.' })
|
||||
@ApiBody({ type: UpdateBotDto })
|
||||
updateBot(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('botId') botId: string,
|
||||
@Body() dto: UpdateBotDto
|
||||
) {
|
||||
return this.auth(authorization).then((requesterId) =>
|
||||
firstValueFrom(this.core.bot.UpdateBot({ requesterId, botId, ...dto, isSuperAdmin: false }))
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':botId')
|
||||
@ApiOperation({ summary: 'Удалить бота', description: 'Удаляет бота и все связанные чаты/сообщения.' })
|
||||
deleteBot(@Headers('authorization') authorization: string | undefined, @Param('botId') botId: string) {
|
||||
return this.auth(authorization).then((requesterId) =>
|
||||
firstValueFrom(this.core.bot.DeleteBot({ requesterId, botId, isSuperAdmin: false }))
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':botId/revoke-token')
|
||||
@ApiOperation({ summary: 'Перевыпустить токен', description: 'Инвалидирует старый токен и возвращает новый.' })
|
||||
revokeToken(@Headers('authorization') authorization: string | undefined, @Param('botId') botId: string) {
|
||||
return this.auth(authorization).then((requesterId) =>
|
||||
firstValueFrom(this.core.bot.RevokeBotToken({ requesterId, botId, isSuperAdmin: false }))
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':botId/profile')
|
||||
@ApiOperation({
|
||||
summary: 'Профиль бота',
|
||||
description: 'Обновляет description, aboutText, botPicUrl и глобальную кнопку меню (Web App).'
|
||||
})
|
||||
@ApiBody({ type: UpdateBotProfileDto })
|
||||
updateBotProfile(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('botId') botId: string,
|
||||
@Body() dto: UpdateBotProfileDto
|
||||
) {
|
||||
return this.auth(authorization).then((requesterId) =>
|
||||
firstValueFrom(this.core.bot.UpdateBotProfile({ requesterId, botId, ...dto, isSuperAdmin: false }))
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':botId/web-app')
|
||||
@ApiOperation({ summary: 'Настроить Mini App', description: 'Привязывает URL Web App к боту.' })
|
||||
@ApiBody({ type: SetBotWebAppDto })
|
||||
setWebApp(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('botId') botId: string,
|
||||
@Body() dto: SetBotWebAppDto
|
||||
) {
|
||||
return this.auth(authorization).then((requesterId) =>
|
||||
firstValueFrom(this.core.bot.SetBotWebApp({ requesterId, botId, webAppUrl: dto.webAppUrl, isSuperAdmin: false }))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,13 @@ import { getAuthorizedUserId } from '../document-access';
|
||||
import {
|
||||
AddChatRoomMemberDto,
|
||||
CreateChatRoomDto,
|
||||
CreateE2EChatRoomDto,
|
||||
EditChatMessageDto,
|
||||
ForwardMessagesDto,
|
||||
MarkRoomReadDto,
|
||||
SendChatMessageDto,
|
||||
SetRoomNotificationsMutedDto,
|
||||
ToggleMessageReactionDto,
|
||||
UpdateChatRoomDto,
|
||||
VotePollDto
|
||||
} from '../dto/chat.dto';
|
||||
@@ -56,6 +59,17 @@ export class ChatController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('groups/:groupId/e2e-rooms')
|
||||
@ApiOperation({ summary: 'Создать секретный E2E чат' })
|
||||
async createE2ERoom(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() dto: CreateE2EChatRoomDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.chat.CreateE2ERoom({ userId, groupId, peerUserId: dto.peerUserId }));
|
||||
}
|
||||
|
||||
@Patch('rooms/:roomId')
|
||||
@ApiOperation({ summary: 'Настройки чата' })
|
||||
async updateRoom(
|
||||
@@ -138,7 +152,8 @@ export class ChatController {
|
||||
storageKey: dto.storageKey,
|
||||
mimeType: dto.mimeType,
|
||||
metadataJson: dto.metadataJson,
|
||||
poll: dto.poll
|
||||
poll: dto.poll,
|
||||
isEncrypted: dto.isEncrypted
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -183,6 +198,35 @@ export class ChatController {
|
||||
return firstValueFrom(this.core.chat.DeleteMessage({ userId, messageId }));
|
||||
}
|
||||
|
||||
@Post('messages/:messageId/reactions')
|
||||
@ApiOperation({ summary: 'Поставить или снять реакцию' })
|
||||
async toggleReaction(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('messageId') messageId: string,
|
||||
@Body() dto: ToggleMessageReactionDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.chat.ToggleMessageReaction({ userId, messageId, emoji: dto.emoji }));
|
||||
}
|
||||
|
||||
@Post('rooms/:roomId/forward')
|
||||
@ApiOperation({ summary: 'Переслать сообщения в другой чат' })
|
||||
async forwardMessages(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string,
|
||||
@Body() dto: ForwardMessagesDto
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(
|
||||
this.core.chat.ForwardMessages({ userId, targetRoomId: roomId, messageIds: dto.messageIds }).pipe(
|
||||
map((response) => {
|
||||
const payload = response as { messages?: unknown[] };
|
||||
return { messages: payload.messages ?? [] };
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Post('rooms/:roomId/read')
|
||||
@ApiOperation({ summary: 'Отметить сообщения чата прочитанными' })
|
||||
async markRoomRead(
|
||||
@@ -195,4 +239,21 @@ export class ChatController {
|
||||
this.core.chat.MarkRoomRead({ userId, roomId, lastMessageId: dto.lastMessageId })
|
||||
);
|
||||
}
|
||||
|
||||
@Post('rooms/:roomId/typing')
|
||||
@ApiOperation({ summary: 'Сообщить о наборе текста в чате' })
|
||||
async reportTyping(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('roomId') roomId: string
|
||||
) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.chat.ReportTyping({ userId, roomId }));
|
||||
}
|
||||
|
||||
@Delete('rooms/:roomId')
|
||||
@ApiOperation({ summary: 'Удалить чат' })
|
||||
async deleteRoom(@Headers('authorization') authorization: string | undefined, @Param('roomId') roomId: string) {
|
||||
const userId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.chat.DeleteRoom({ userId, roomId }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { getAuthorizedUserId } from '../document-access';
|
||||
import { AddFamilyMemberDto, CreateFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto';
|
||||
import { AddFamilyMemberDto, AddBotToFamilyDto, CreateFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto';
|
||||
|
||||
@ApiTags('Семья')
|
||||
@ApiBearerAuth()
|
||||
@@ -151,5 +151,24 @@ export class FamilyController {
|
||||
const requesterId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.family.GetFamilyPresence({ requesterId, groupId }));
|
||||
}
|
||||
|
||||
@Post('groups/:groupId/botfather')
|
||||
@ApiOperation({ summary: 'Добавить BotFather в семью', description: 'Добавляет системного бота BotFather для создания Telegram-ботов через чат.' })
|
||||
async addBotFather(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
|
||||
const requesterId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.family.AddBotFatherToFamily({ requesterId, groupId }));
|
||||
}
|
||||
|
||||
@Post('groups/:groupId/bots')
|
||||
@ApiOperation({ summary: 'Добавить бота в семью', description: 'Добавляет Telegram-бота (своего или другого пользователя) в семейную группу.' })
|
||||
@ApiBody({ type: AddBotToFamilyDto })
|
||||
async addBot(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() dto: AddBotToFamilyDto
|
||||
) {
|
||||
const requesterId = await this.auth(authorization);
|
||||
return firstValueFrom(this.core.family.AddBotToFamily({ requesterId, groupId, botId: dto.botId }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,129 @@
|
||||
import { Body, Controller, Get, Headers, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Headers, Post, Query, Res, UnauthorizedException, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
import { OAuthAuthorizeQueryDto, OAuthTokenDto } from '../dto/identity.dto';
|
||||
import { OAuthAuthorizeIncomingDto, OAuthTokenIncomingDto } from '../dto/oauth.dto';
|
||||
import { extractBearerToken } from '../auth-token';
|
||||
import { appendQueryParams, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, normalizeTokenBody } from '../lib/oauth-params';
|
||||
import { resolveFrontendUrl } from '../lib/oauth-issuer';
|
||||
import { verifyAccessToken } from '../session-auth';
|
||||
|
||||
type HttpResponse = {
|
||||
redirect(status: number, url: string): void;
|
||||
};
|
||||
|
||||
const oauthValidationPipe = new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: false
|
||||
});
|
||||
|
||||
@ApiTags('OAuth 2.0')
|
||||
@Controller('oauth')
|
||||
export class OAuthController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
@Get('authorize')
|
||||
@ApiOperation({ summary: 'OAuth авторизация', description: 'Создает authorization_code и возвращает redirectUrl для OAuth клиента. Consent считается подтвержденным для переданного userId.' })
|
||||
@ApiQuery({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiQuery({ name: 'clientId', description: 'OAuth client_id' })
|
||||
@ApiQuery({ name: 'redirectUri', description: 'redirect_uri' })
|
||||
@ApiQuery({ name: 'scope', description: 'Scopes через пробел' })
|
||||
@ApiResponse({ status: 200, description: 'Authorization code создан' })
|
||||
authorize(@Query() query: OAuthAuthorizeQueryDto) {
|
||||
return this.core.oauth.Authorize(query);
|
||||
@UsePipes(oauthValidationPipe)
|
||||
@ApiOperation({
|
||||
summary: 'OAuth / OIDC авторизация',
|
||||
description:
|
||||
'Поддерживает стандартные query-параметры RFC 6749 / OIDC (client_id, redirect_uri, response_type, code_challenge) и legacy camelCase. ' +
|
||||
'Без userId и без Bearer-токена перенаправляет на страницу подтверждения доступа.'
|
||||
})
|
||||
@ApiQuery({ name: 'client_id', required: false, description: 'OAuth client_id (RFC 6749)' })
|
||||
@ApiQuery({ name: 'clientId', required: false, description: 'OAuth client_id (legacy)' })
|
||||
@ApiQuery({ name: 'redirect_uri', required: false })
|
||||
@ApiQuery({ name: 'redirectUri', required: false })
|
||||
@ApiQuery({ name: 'scope', required: false })
|
||||
@ApiQuery({ name: 'state', required: false })
|
||||
@ApiQuery({ name: 'response_type', required: false, example: 'code' })
|
||||
@ApiQuery({ name: 'code_challenge', required: false })
|
||||
@ApiQuery({ name: 'code_challenge_method', required: false })
|
||||
@ApiQuery({ name: 'userId', required: false, description: 'Legacy: ID пользователя после входа' })
|
||||
@ApiResponse({ status: 302, description: 'Redirect на redirect_uri с authorization code' })
|
||||
async authorize(
|
||||
@Query() query: OAuthAuthorizeIncomingDto,
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Headers('accept') acceptHeader: string | undefined,
|
||||
@Res({ passthrough: true }) res: HttpResponse
|
||||
) {
|
||||
const normalized = normalizeAuthorizeQuery(query as Record<string, unknown>);
|
||||
let userId = normalized.userId;
|
||||
|
||||
if (!userId && authorization) {
|
||||
try {
|
||||
const payload = await verifyAccessToken(this.jwt, authorization);
|
||||
userId = payload.sub;
|
||||
} catch {
|
||||
// handled below
|
||||
}
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
const frontendUrl = await resolveFrontendUrl(this.core);
|
||||
const consentUrl = appendQueryParams(`${frontendUrl}/auth/oauth/authorize`, query as Record<string, unknown>);
|
||||
res.redirect(302, consentUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = (await firstValueFrom(
|
||||
this.core.oauth.Authorize({
|
||||
userId,
|
||||
clientId: normalized.clientId,
|
||||
redirectUri: normalized.redirectUri,
|
||||
scope: normalized.scope,
|
||||
state: normalized.state
|
||||
})
|
||||
)) as { redirectUrl?: string; code?: string; state?: string };
|
||||
|
||||
const accept = acceptHeader ?? '';
|
||||
const wantsJson = authorization?.startsWith('Bearer') || accept.includes('application/json');
|
||||
if (wantsJson) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!result.redirectUrl) {
|
||||
throw new UnauthorizedException('Не удалось создать authorization code');
|
||||
}
|
||||
|
||||
res.redirect(302, result.redirectUrl);
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
@ApiOperation({ summary: 'Выдать OAuth токены', description: 'Поддерживает grant_type=authorization_code и grant_type=refresh_token.' })
|
||||
@ApiBody({ type: OAuthTokenDto })
|
||||
@UsePipes(oauthValidationPipe)
|
||||
@ApiOperation({
|
||||
summary: 'Выдать OAuth токены',
|
||||
description:
|
||||
'Поддерживает grant_type=authorization_code и grant_type=refresh_token. ' +
|
||||
'Принимает application/x-www-form-urlencoded (RFC 6749) и JSON. ' +
|
||||
'Ответ — snake_case: access_token, token_type, expires_in, refresh_token, id_token.'
|
||||
})
|
||||
@ApiBody({ type: OAuthTokenIncomingDto })
|
||||
@ApiResponse({ status: 201, description: 'OAuth токены выданы' })
|
||||
token(@Body() dto: OAuthTokenDto) {
|
||||
return this.core.oauth.Token(dto);
|
||||
async token(@Body() body: OAuthTokenIncomingDto, @Headers('authorization') authorization?: string) {
|
||||
const normalized = mergeTokenCredentials(normalizeTokenBody(body as Record<string, unknown>), authorization);
|
||||
const result = (await firstValueFrom(
|
||||
this.core.oauth.Token({
|
||||
grantType: normalized.grantType,
|
||||
code: normalized.code,
|
||||
refreshToken: normalized.refreshToken,
|
||||
clientId: normalized.clientId,
|
||||
clientSecret: normalized.clientSecret,
|
||||
redirectUri: normalized.redirectUri
|
||||
})
|
||||
)) as {
|
||||
accessToken?: string;
|
||||
tokenType?: string;
|
||||
expiresIn?: number;
|
||||
refreshToken?: string;
|
||||
idToken?: string;
|
||||
};
|
||||
return mapTokenResponseToStandard(result);
|
||||
}
|
||||
|
||||
@Get('userinfo')
|
||||
|
||||
@@ -139,5 +139,22 @@ export class ProfileController {
|
||||
await this.assertSelfAccess(authorization, userId);
|
||||
return this.core.profile.GetAccountDeletionStatus({ userId });
|
||||
}
|
||||
|
||||
@Get('e2e-public-key')
|
||||
@ApiOperation({ summary: 'Получить публичный E2E-ключ пользователя' })
|
||||
async getE2EPublicKey(@Param('userId') userId: string) {
|
||||
return this.core.profile.GetE2EPublicKey({ userId });
|
||||
}
|
||||
|
||||
@Patch('e2e-public-key')
|
||||
@ApiOperation({ summary: 'Сохранить свой публичный E2E-ключ' })
|
||||
async setE2EPublicKey(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: { publicKey: string }
|
||||
) {
|
||||
await this.assertSelfAccess(authorization, userId);
|
||||
return this.core.profile.SetE2EPublicKey({ userId, publicKey: dto.publicKey });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { All, Body, Controller, HttpCode, Param, Req, Res } from '@nestjs/common';
|
||||
import { ApiExcludeController } 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;
|
||||
};
|
||||
|
||||
@ApiExcludeController()
|
||||
@Controller()
|
||||
export class TelegramBotApiController {
|
||||
constructor(private readonly core: CoreGrpcService) {}
|
||||
|
||||
@All(':botPath/:method')
|
||||
@HttpCode(200)
|
||||
async handleTelegramMethod(
|
||||
@Req() request: HttpRequest,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
@Param('botPath') botPath: string,
|
||||
@Param('method') method: string,
|
||||
@Body() body: 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 payload = request.method === 'GET' ? { ...request.query } : body ?? {};
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user