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

@@ -20,6 +20,9 @@ import { FamilyController } from './controllers/family.controller';
import { ChatController } from './controllers/chat.controller';
import { NotificationsController } from './controllers/notifications.controller';
import { MediaController } from './controllers/media.controller';
import { BotController } from './controllers/bot.controller';
import { AdminBotController } from './controllers/admin-bot.controller';
import { TelegramBotApiController } from './controllers/telegram-bot-api.controller';
import { CoreGrpcService } from './core-grpc.service';
import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.guard';
@@ -35,7 +38,7 @@ import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.gua
useFactory: (config: ConfigService) => ({
transport: Transport.GRPC,
options: {
package: ['auth', 'admin', 'rbac', 'security', 'profile', 'documents', 'addresses', 'identity', 'media', 'notifications', 'chat'],
package: ['auth', 'admin', 'rbac', 'security', 'profile', 'documents', 'addresses', 'identity', 'media', 'notifications', 'chat', 'bot'],
protoPath: [
join(__dirname, '../../../shared/proto/auth.proto'),
join(__dirname, '../../../shared/proto/admin.proto'),
@@ -47,7 +50,8 @@ import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.gua
join(__dirname, '../../../shared/proto/identity.proto'),
join(__dirname, '../../../shared/proto/media.proto'),
join(__dirname, '../../../shared/proto/notifications.proto'),
join(__dirname, '../../../shared/proto/chat.proto')
join(__dirname, '../../../shared/proto/chat.proto'),
join(__dirname, '../../../shared/proto/bot.proto')
],
url: config.get<string>('SSO_CORE_GRPC_URL', 'localhost:50051')
}
@@ -55,7 +59,7 @@ import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.gua
}
])
],
controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, WellKnownController, OAuthAuthorizeDiscoveryController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController],
controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, WellKnownController, OAuthAuthorizeDiscoveryController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController, BotController, AdminBotController, TelegramBotApiController],
providers: [CoreGrpcService, AdminGuard, RbacManageGuard, SuperAdminGuard]
})
export class AppModule {}

View 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
})
);
}
}

View 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 }))
);
}
}

View File

@@ -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 }));
}
}

View File

@@ -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 }));
}
}

View File

@@ -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')

View File

@@ -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 });
}
}

View File

@@ -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);
}
}

View File

@@ -21,6 +21,7 @@ export class CoreGrpcService implements OnModuleInit {
chat!: Record<string, GrpcMethod>;
family!: Record<string, GrpcMethod>;
media!: Record<string, GrpcMethod>;
bot!: Record<string, GrpcMethod>;
constructor(@Inject('SSO_CORE') private readonly client: ClientGrpc) {}
@@ -40,5 +41,6 @@ export class CoreGrpcService implements OnModuleInit {
this.notifications = this.client.getService<Record<string, GrpcMethod>>('NotificationsService');
this.chat = this.client.getService<Record<string, GrpcMethod>>('ChatService');
this.media = this.client.getService<Record<string, GrpcMethod>>('MediaService');
this.bot = this.client.getService<Record<string, GrpcMethod>>('BotService');
}
}

View File

@@ -0,0 +1,101 @@
import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Max, Min, MinLength } from 'class-validator';
export class CreateBotDto {
@IsString()
@MinLength(2, { message: 'Название бота должно содержать минимум 2 символа' })
name!: string;
@IsString()
@MinLength(5, { message: 'Username бота должен содержать минимум 5 символов' })
username!: string;
}
export class UpdateBotDto {
@IsOptional()
@IsString()
@MinLength(2, { message: 'Название бота должно содержать минимум 2 символа' })
name?: string;
@IsOptional()
@IsString()
@MinLength(5, { message: 'Username бота должен содержать минимум 5 символов' })
username?: string;
}
export class SetBotWebAppDto {
@IsOptional()
@IsUrl({}, { message: 'Укажите корректный URL Mini App' })
webAppUrl?: string;
}
export class UpdateBotProfileDto {
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
aboutText?: string;
@IsOptional()
@IsString()
botPicUrl?: string;
@IsOptional()
@IsString()
menuButtonJson?: string;
@IsOptional()
@IsString()
menuButtonUrl?: string;
@IsOptional()
@IsString()
menuButtonText?: string;
}
export class SetBotActiveDto {
@IsBoolean()
isActive!: boolean;
}
export class ListAdminBotsQueryDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}
export class ValidateWebAppInitDataDto {
@IsString()
initData!: string;
@IsString()
botToken!: string;
}
export class SubmitBotMessageDto {
@IsString()
@MinLength(1, { message: 'Текст сообщения не может быть пустым' })
text!: string;
}
export class SubmitBotCallbackDto {
@IsInt()
@Min(1)
messageId!: number;
@IsString()
@MinLength(1, { message: 'callbackData не может быть пустым' })
callbackData!: string;
}

View File

@@ -1,4 +1,4 @@
import { IsArray, IsBoolean, IsIn, IsOptional, IsString, MinLength } from 'class-validator';
import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsOptional, IsString, MinLength } from 'class-validator';
export class CreateChatRoomDto {
@IsString()
@@ -11,6 +11,11 @@ export class CreateChatRoomDto {
memberUserIds?: string[];
}
export class CreateE2EChatRoomDto {
@IsString()
peerUserId!: string;
}
export class UpdateChatRoomDto {
@IsOptional()
@IsString()
@@ -54,6 +59,10 @@ export class SendChatMessageDto {
allowsMultiple?: boolean;
isAnonymous?: boolean;
};
@IsOptional()
@IsBoolean()
isEncrypted?: boolean;
}
export class VotePollDto {
@@ -83,3 +92,16 @@ export class MarkRoomReadDto {
@IsString()
lastMessageId?: string;
}
export class ToggleMessageReactionDto {
@IsString()
@MinLength(1)
emoji!: string;
}
export class ForwardMessagesDto {
@IsArray()
@ArrayNotEmpty()
@IsString({ each: true })
messageIds!: string[];
}

View File

@@ -153,3 +153,10 @@ export class RespondFamilyInviteDto {
@IsBoolean({ message: 'Укажите accept: true или false' })
accept!: boolean;
}
export class AddBotToFamilyDto {
@ApiProperty({ description: 'ID бота для добавления в семью' })
@IsString({ message: 'ID бота должен быть строкой' })
@IsNotEmpty({ message: 'Укажите ID бота' })
botId!: string;
}

View File

@@ -0,0 +1,133 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
export class OAuthAuthorizeIncomingDto {
@ApiPropertyOptional({ description: 'ID пользователя (legacy camelCase)' })
@IsOptional()
@IsString()
userId?: string;
@ApiPropertyOptional({ description: 'ID пользователя (OIDC legacy alias)' })
@IsOptional()
@IsString()
user_id?: string;
@ApiPropertyOptional({ description: 'OAuth client_id (legacy camelCase)' })
@IsOptional()
@IsString()
clientId?: string;
@ApiPropertyOptional({ description: 'OAuth client_id (RFC 6749)' })
@IsOptional()
@IsString()
client_id?: string;
@ApiPropertyOptional({ description: 'redirect_uri (legacy camelCase)' })
@IsOptional()
@IsString()
redirectUri?: string;
@ApiPropertyOptional({ description: 'redirect_uri (RFC 6749)' })
@IsOptional()
@IsString()
redirect_uri?: string;
@ApiPropertyOptional({ description: 'Scopes через пробел', example: 'openid profile email' })
@IsOptional()
@IsString()
scope?: string;
@ApiPropertyOptional({ description: 'OAuth state' })
@IsOptional()
@IsString()
state?: string;
@ApiPropertyOptional({ description: 'OAuth response_type', example: 'code' })
@IsOptional()
@IsString()
response_type?: string;
@ApiPropertyOptional({ description: 'Legacy camelCase alias response_type' })
@IsOptional()
@IsString()
responseType?: string;
@ApiPropertyOptional({ description: 'PKCE code_challenge' })
@IsOptional()
@IsString()
code_challenge?: string;
@ApiPropertyOptional({ description: 'Legacy camelCase alias code_challenge' })
@IsOptional()
@IsString()
codeChallenge?: string;
@ApiPropertyOptional({ description: 'PKCE code_challenge_method', example: 'S256' })
@IsOptional()
@IsString()
code_challenge_method?: string;
@ApiPropertyOptional({ description: 'Legacy camelCase alias code_challenge_method' })
@IsOptional()
@IsString()
codeChallengeMethod?: string;
@ApiPropertyOptional({ description: 'OpenID Connect nonce' })
@IsOptional()
@IsString()
nonce?: string;
}
export class OAuthTokenIncomingDto {
@IsOptional()
@IsString()
grantType?: string;
@IsOptional()
@IsString()
grant_type?: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsString()
refreshToken?: string;
@IsOptional()
@IsString()
refresh_token?: string;
@IsOptional()
@IsString()
clientId?: string;
@IsOptional()
@IsString()
client_id?: string;
@IsOptional()
@IsString()
clientSecret?: string;
@IsOptional()
@IsString()
client_secret?: string;
@IsOptional()
@IsString()
redirectUri?: string;
@IsOptional()
@IsString()
redirect_uri?: string;
@IsOptional()
@IsString()
codeVerifier?: string;
@IsOptional()
@IsString()
code_verifier?: string;
}

View File

@@ -37,6 +37,38 @@ export async function resolveOAuthIssuer(core: CoreGrpcService, fallback = 'http
return normalizeBaseUrl(fallback);
}
export async function resolveFrontendUrl(core: CoreGrpcService, fallback = 'http://localhost:3002'): Promise<string> {
const envFrontend = process.env.PUBLIC_FRONTEND_URL?.trim();
if (envFrontend) {
return normalizeBaseUrl(envFrontend);
}
try {
const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PUBLIC_FRONTEND_URL' }))) as { value?: string };
const fromDb = response.value?.trim();
if (fromDb) {
return normalizeBaseUrl(fromDb);
}
} catch {
// fallback ниже
}
try {
const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PROJECT_DOMAIN' }))) as { value?: string };
const domain = response.value?.trim();
if (domain) {
if (domain.startsWith('http://') || domain.startsWith('https://')) {
return normalizeBaseUrl(domain);
}
return `https://${domain.replace(/^\/+/, '')}`;
}
} catch {
// fallback ниже
}
return normalizeBaseUrl(fallback);
}
export function buildOpenIdConfiguration(issuer: string) {
const base = normalizeBaseUrl(issuer);
return {
@@ -49,8 +81,9 @@ export function buildOpenIdConfiguration(issuer: string) {
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['HS256'],
scopes_supported: ['openid', 'profile', 'email', 'phone', 'address', 'documents'],
token_endpoint_auth_methods_supported: ['client_secret_post'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256', 'plain'],
claims_supported: ['sub', 'email', 'phone', 'name', 'picture']
};
}

View File

@@ -0,0 +1,147 @@
import { BadRequestException } from '@nestjs/common';
export interface NormalizedAuthorizeQuery {
userId?: string;
clientId: string;
redirectUri: string;
scope: string;
state?: string;
responseType?: string;
codeChallenge?: string;
codeChallengeMethod?: string;
nonce?: string;
}
export interface NormalizedTokenBody {
grantType: string;
code?: string;
refreshToken?: string;
clientId: string;
clientSecret?: string;
redirectUri?: string;
codeVerifier?: string;
}
function readString(value: unknown) {
if (typeof value === 'string') return value.trim();
if (Array.isArray(value) && typeof value[0] === 'string') return value[0].trim();
return undefined;
}
export function normalizeAuthorizeQuery(query: Record<string, unknown>): NormalizedAuthorizeQuery {
const clientId = readString(query.clientId) ?? readString(query.client_id);
const redirectUri = readString(query.redirectUri) ?? readString(query.redirect_uri);
const scope = readString(query.scope) ?? 'openid profile';
const userId = readString(query.userId) ?? readString(query.user_id);
const state = readString(query.state);
const responseType = readString(query.response_type) ?? readString(query.responseType);
const codeChallenge = readString(query.code_challenge) ?? readString(query.codeChallenge);
const codeChallengeMethod = readString(query.code_challenge_method) ?? readString(query.codeChallengeMethod);
const nonce = readString(query.nonce);
if (!clientId) {
throw new BadRequestException('Укажите client_id');
}
if (!redirectUri) {
throw new BadRequestException('Укажите redirect_uri');
}
if (responseType && responseType !== 'code') {
throw new BadRequestException('Поддерживается только response_type=code');
}
return {
userId,
clientId,
redirectUri,
scope,
state,
responseType,
codeChallenge,
codeChallengeMethod,
nonce
};
}
export function normalizeTokenBody(body: Record<string, unknown>): NormalizedTokenBody {
const grantType = readString(body.grantType) ?? readString(body.grant_type);
const clientId = readString(body.clientId) ?? readString(body.client_id);
const clientSecret = readString(body.clientSecret) ?? readString(body.client_secret);
const code = readString(body.code);
const refreshToken = readString(body.refreshToken) ?? readString(body.refresh_token);
const redirectUri = readString(body.redirectUri) ?? readString(body.redirect_uri);
const codeVerifier = readString(body.codeVerifier) ?? readString(body.code_verifier);
if (!grantType) {
throw new BadRequestException('Укажите grant_type');
}
if (!clientId) {
throw new BadRequestException('Укажите client_id');
}
return {
grantType,
clientId,
clientSecret,
code,
refreshToken,
redirectUri,
codeVerifier
};
}
export function appendQueryParams(baseUrl: string, query: Record<string, unknown>) {
const url = new URL(baseUrl);
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value)) {
value.forEach((item) => url.searchParams.append(key, String(item)));
continue;
}
url.searchParams.set(key, String(value));
}
return url.toString();
}
export function parseBasicClientCredentials(authorization?: string) {
if (!authorization?.startsWith('Basic ')) return null;
try {
const decoded = Buffer.from(authorization.slice(6), 'base64').toString('utf8');
const separator = decoded.indexOf(':');
if (separator < 0) return null;
return {
clientId: decoded.slice(0, separator),
clientSecret: decoded.slice(separator + 1)
};
} catch {
return null;
}
}
export function mapTokenResponseToStandard(result: {
accessToken?: string;
tokenType?: string;
expiresIn?: number;
refreshToken?: string;
idToken?: string;
}) {
return {
access_token: result.accessToken,
token_type: result.tokenType ?? 'Bearer',
expires_in: result.expiresIn ?? 900,
refresh_token: result.refreshToken,
id_token: result.idToken
};
}
export function mergeTokenCredentials(
body: NormalizedTokenBody,
authorization?: string
): NormalizedTokenBody {
const basic = parseBasicClientCredentials(authorization);
if (!basic) return body;
return {
...body,
clientId: body.clientId || basic.clientId,
clientSecret: body.clientSecret || basic.clientSecret
};
}