diff --git a/apps/api-gateway/src/app.module.ts b/apps/api-gateway/src/app.module.ts index 7489d37..56cbba1 100644 --- a/apps/api-gateway/src/app.module.ts +++ b/apps/api-gateway/src/app.module.ts @@ -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('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 {} diff --git a/apps/api-gateway/src/controllers/admin-bot.controller.ts b/apps/api-gateway/src/controllers/admin-bot.controller.ts new file mode 100644 index 0000000..ae20751 --- /dev/null +++ b/apps/api-gateway/src/controllers/admin-bot.controller.ts @@ -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 + }) + ); + } +} diff --git a/apps/api-gateway/src/controllers/bot.controller.ts b/apps/api-gateway/src/controllers/bot.controller.ts new file mode 100644 index 0000000..723889d --- /dev/null +++ b/apps/api-gateway/src/controllers/bot.controller.ts @@ -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 })) + ); + } +} diff --git a/apps/api-gateway/src/controllers/chat.controller.ts b/apps/api-gateway/src/controllers/chat.controller.ts index 14fea7c..99ea744 100644 --- a/apps/api-gateway/src/controllers/chat.controller.ts +++ b/apps/api-gateway/src/controllers/chat.controller.ts @@ -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 })); + } } diff --git a/apps/api-gateway/src/controllers/family.controller.ts b/apps/api-gateway/src/controllers/family.controller.ts index 760093e..bc9f4a0 100644 --- a/apps/api-gateway/src/controllers/family.controller.ts +++ b/apps/api-gateway/src/controllers/family.controller.ts @@ -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 })); + } } diff --git a/apps/api-gateway/src/controllers/oauth.controller.ts b/apps/api-gateway/src/controllers/oauth.controller.ts index 24c051a..253eff2 100644 --- a/apps/api-gateway/src/controllers/oauth.controller.ts +++ b/apps/api-gateway/src/controllers/oauth.controller.ts @@ -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); + 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); + 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), 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') diff --git a/apps/api-gateway/src/controllers/profile.controller.ts b/apps/api-gateway/src/controllers/profile.controller.ts index 626fb59..2a9409e 100644 --- a/apps/api-gateway/src/controllers/profile.controller.ts +++ b/apps/api-gateway/src/controllers/profile.controller.ts @@ -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 }); + } } diff --git a/apps/api-gateway/src/controllers/telegram-bot-api.controller.ts b/apps/api-gateway/src/controllers/telegram-bot-api.controller.ts new file mode 100644 index 0000000..7349ec3 --- /dev/null +++ b/apps/api-gateway/src/controllers/telegram-bot-api.controller.ts @@ -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; +}; + +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 + ) { + 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); + } +} diff --git a/apps/api-gateway/src/core-grpc.service.ts b/apps/api-gateway/src/core-grpc.service.ts index 72f3d30..0fb5bd4 100644 --- a/apps/api-gateway/src/core-grpc.service.ts +++ b/apps/api-gateway/src/core-grpc.service.ts @@ -21,6 +21,7 @@ export class CoreGrpcService implements OnModuleInit { chat!: Record; family!: Record; media!: Record; + bot!: Record; constructor(@Inject('SSO_CORE') private readonly client: ClientGrpc) {} @@ -40,5 +41,6 @@ export class CoreGrpcService implements OnModuleInit { this.notifications = this.client.getService>('NotificationsService'); this.chat = this.client.getService>('ChatService'); this.media = this.client.getService>('MediaService'); + this.bot = this.client.getService>('BotService'); } } diff --git a/apps/api-gateway/src/dto/bot.dto.ts b/apps/api-gateway/src/dto/bot.dto.ts new file mode 100644 index 0000000..eac3dd1 --- /dev/null +++ b/apps/api-gateway/src/dto/bot.dto.ts @@ -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; +} diff --git a/apps/api-gateway/src/dto/chat.dto.ts b/apps/api-gateway/src/dto/chat.dto.ts index 9ba4441..4d84c66 100644 --- a/apps/api-gateway/src/dto/chat.dto.ts +++ b/apps/api-gateway/src/dto/chat.dto.ts @@ -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[]; +} diff --git a/apps/api-gateway/src/dto/identity.dto.ts b/apps/api-gateway/src/dto/identity.dto.ts index 5b04cf2..cd69c36 100644 --- a/apps/api-gateway/src/dto/identity.dto.ts +++ b/apps/api-gateway/src/dto/identity.dto.ts @@ -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; +} diff --git a/apps/api-gateway/src/dto/oauth.dto.ts b/apps/api-gateway/src/dto/oauth.dto.ts new file mode 100644 index 0000000..91c6caa --- /dev/null +++ b/apps/api-gateway/src/dto/oauth.dto.ts @@ -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; +} diff --git a/apps/api-gateway/src/lib/oauth-issuer.ts b/apps/api-gateway/src/lib/oauth-issuer.ts index 52d1815..8c23f73 100644 --- a/apps/api-gateway/src/lib/oauth-issuer.ts +++ b/apps/api-gateway/src/lib/oauth-issuer.ts @@ -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 { + 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'] }; } diff --git a/apps/api-gateway/src/lib/oauth-params.ts b/apps/api-gateway/src/lib/oauth-params.ts new file mode 100644 index 0000000..9e18c5d --- /dev/null +++ b/apps/api-gateway/src/lib/oauth-params.ts @@ -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): 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): 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) { + 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 + }; +} diff --git a/apps/docs/components/bot-code-tabs.tsx b/apps/docs/components/bot-code-tabs.tsx new file mode 100644 index 0000000..15fe499 --- /dev/null +++ b/apps/docs/components/bot-code-tabs.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { buildBotExamples } from '@/lib/bot-examples'; +import { fetchPublicSettingsClient } from '@/lib/api'; +import { resolveOAuthApiBase } from '@/lib/oauth-url'; +import { CodeExampleTabs } from '@/components/code-example-tabs'; + +export function BotCodeTabs() { + const [apiBase, setApiBase] = useState('http://localhost:3000'); + + useEffect(() => { + void fetchPublicSettingsClient() + .then((settings) => setApiBase(resolveOAuthApiBase(settings))) + .catch(() => undefined); + }, []); + + const examples = useMemo(() => buildBotExamples(apiBase), [apiBase]); + + return ( +
+

+ Базовый URL Bot API:{' '} + {apiBase}/bot{'{token}'}/{'{method}'} + {' — '} + подставляется из PUBLIC_API_URL (как для OAuth). +

+ +
+ ); +} diff --git a/apps/docs/components/doc-block-renderer.tsx b/apps/docs/components/doc-block-renderer.tsx index bfc6030..3b37139 100644 --- a/apps/docs/components/doc-block-renderer.tsx +++ b/apps/docs/components/doc-block-renderer.tsx @@ -2,6 +2,7 @@ import type { DocBlock } from '@/lib/docs-pages'; import { OAuthCodeTabs } from '@/components/oauth-code-tabs'; import { AuthLoginCodeTabs } from '@/components/auth-code-tabs'; import { AuthLdapCodeTabs } from '@/components/auth-ldap-code-tabs'; +import { BotCodeTabs } from '@/components/bot-code-tabs'; import { ApiReferenceSection } from '@/components/api-endpoint-card'; import { CodeBlock } from '@/components/code-block'; import { cn } from '@/lib/utils'; @@ -70,6 +71,8 @@ export function DocBlockRenderer({ block }: { block: DocBlock }) { return ; case 'auth-ldap-examples': return ; + case 'bot-examples': + return ; case 'api-reference': return ; default: diff --git a/apps/docs/components/docs-home-content.tsx b/apps/docs/components/docs-home-content.tsx index 1df2878..6d5bf00 100644 --- a/apps/docs/components/docs-home-content.tsx +++ b/apps/docs/components/docs-home-content.tsx @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; -import { ArrowRight, BookOpen, Code2, Rocket, Shield } from 'lucide-react'; +import { ArrowRight, BookOpen, Bot, Code2, Rocket, Shield } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { docNavigation, groupDocNavigation } from '@/lib/navigation'; @@ -16,10 +16,16 @@ const highlights = [ }, { icon: Code2, - title: 'OAuth 2.0', - description: 'Примеры интеграции на JavaScript, Python, PHP, Go и других языках.', + title: 'OAuth 2.0 / OIDC', + description: 'Стандартный OpenID Connect: Discovery, client_id, PKCE, form-urlencoded token. Примеры для PHP без доработки OidcProvider.', href: '/docs/oauth' }, + { + icon: Bot, + title: 'Telegram Bot API', + description: 'Telegraf, BotFather, профиль бота, setChatMenuButton и Mini Apps — совместимость с Telegram без смены кода.', + href: '/docs/bot-api' + }, { icon: Shield, title: 'Безопасность', diff --git a/apps/docs/lib/api-endpoints.ts b/apps/docs/lib/api-endpoints.ts index 717ca3e..e8bbf94 100644 --- a/apps/docs/lib/api-endpoints.ts +++ b/apps/docs/lib/api-endpoints.ts @@ -31,12 +31,39 @@ export const apiReference: ApiTagGroup[] = [ ] }, { - tag: 'OAuth 2.0', + tag: 'OAuth 2.0 / OIDC', endpoints: [ - { method: 'GET', path: '/.well-known/openid-configuration', summary: 'OpenID Connect Discovery' }, - { method: 'GET', path: '/oauth/authorize', summary: 'Создать authorization code' }, - { method: 'POST', path: '/oauth/token', summary: 'Выдать OAuth токены (code / refresh_token)' }, - { method: 'GET', path: '/oauth/userinfo', summary: 'Профиль по OAuth access token', auth: true } + { + method: 'GET', + path: '/.well-known/openid-configuration', + summary: 'OpenID Connect Discovery', + description: 'Метаданные провайдера: issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, scopes_supported, code_challenge_methods_supported.' + }, + { + method: 'GET', + path: '/oauth/authorize', + summary: 'Authorization endpoint (OIDC)', + description: + 'Стандартные query: client_id, redirect_uri, response_type=code, scope, state, code_challenge, code_challenge_method. ' + + 'HTTP 302 на redirect_uri?code=... или редирект на экран входа/подтверждения. Legacy: clientId, redirectUri, userId.' + }, + { + method: 'POST', + path: '/oauth/token', + summary: 'Token endpoint', + description: + 'Content-Type: application/x-www-form-urlencoded или JSON. ' + + 'Поля: grant_type, code, client_id, client_secret, redirect_uri, refresh_token. ' + + 'Ответ (snake_case): access_token, token_type, expires_in, refresh_token, id_token. ' + + 'Поддерживается Authorization: Basic (client_id:client_secret).' + }, + { + method: 'GET', + path: '/oauth/userinfo', + summary: 'UserInfo endpoint', + description: 'Профиль по Bearer access_token. Поля: sub, email, phone, name, picture.', + auth: true + } ] }, { @@ -47,6 +74,20 @@ export const apiReference: ApiTagGroup[] = [ { method: 'PATCH', path: '/profile/users/{userId}/avatar', summary: 'Обновить аватар', auth: true }, { method: 'PATCH', path: '/profile/users/{userId}/contacts', summary: 'Обновить контакты', auth: true }, { method: 'POST', path: '/profile/users/{userId}/password', summary: 'Установить пароль', auth: true }, + { + method: 'GET', + path: '/profile/users/{userId}/e2e-public-key', + summary: 'Публичный E2E-ключ пользователя', + description: 'SPKI base64 для ECDH P-256. Нужен перед созданием секретного чата.', + auth: false + }, + { + method: 'PATCH', + path: '/profile/users/{userId}/e2e-public-key', + summary: 'Сохранить свой E2E-ключ', + description: 'Тело: { "publicKey": "..." }. Только для своего userId.', + auth: true + }, { method: 'POST', path: '/profile/users/{userId}/self-delete', @@ -133,15 +174,63 @@ export const apiReference: ApiTagGroup[] = [ { method: 'GET', path: '/family/groups/{groupId}/invite-search', summary: 'Поиск пользователей для приглашения', auth: true }, { method: 'GET', path: '/family/invites', summary: 'Входящие приглашения', auth: true }, { method: 'POST', path: '/family/invites/{inviteId}/respond', summary: 'Принять или отклонить приглашение', auth: true }, - { method: 'GET', path: '/family/groups/{groupId}/presence', summary: 'Онлайн-статус участников семьи', auth: true } + { method: 'GET', path: '/family/groups/{groupId}/presence', summary: 'Онлайн-статус участников семьи', auth: true }, + { + method: 'POST', + path: '/family/groups/{groupId}/botfather', + summary: 'Добавить BotFather в семью', + description: 'Создаёт BOT-чат с системным ботом BotFather для управления Telegram-ботами.', + auth: true + } ] }, { tag: 'Чат', endpoints: [ - { method: 'GET', path: '/chat/groups/{groupId}/rooms', summary: 'Список чатов семьи', auth: true }, - { method: 'POST', path: '/chat/rooms/{roomId}/messages', summary: 'Отправить сообщение', auth: true }, - { method: 'GET', path: '/chat/rooms/{roomId}/messages', summary: 'Сообщения чата', auth: true } + { + method: 'GET', + path: '/chat/groups/{groupId}/rooms', + summary: 'Список чатов семьи', + description: 'Автосинхронизация DIRECT/BOT через syncFamilyChats. Поля: type, peerUserId, botUsername, isE2E.', + auth: true + }, + { + method: 'POST', + path: '/chat/groups/{groupId}/rooms', + summary: 'Создать групповой чат', + description: 'Минимум 3 участника. Личные DIRECT создаются автоматически.', + auth: true + }, + { + method: 'POST', + path: '/chat/groups/{groupId}/e2e-rooms', + summary: 'Создать секретный E2E-чат', + description: 'Тело: { "peerUserId": "..." }. Только с участниками семьи (не ботами).', + auth: true + }, + { method: 'PATCH', path: '/chat/rooms/{roomId}', summary: 'Настройки чата (название, mute)', auth: true }, + { method: 'DELETE', path: '/chat/rooms/{roomId}', summary: 'Удалить чат', description: 'GENERAL удалить нельзя. E2E, DIRECT, BOT, GROUP — доступно участникам.', auth: true }, + { method: 'POST', path: '/chat/rooms/{roomId}/members', summary: 'Добавить участника в групповой чат', auth: true }, + { method: 'DELETE', path: '/chat/rooms/{roomId}/members/{memberUserId}', summary: 'Удалить участника из чата', auth: true }, + { + method: 'GET', + path: '/chat/rooms/{roomId}/messages', + summary: 'Сообщения чата', + description: 'Query: limit (по умолчанию 50), beforeMessageId. Поле isEncrypted на сообщениях E2E.', + auth: true + }, + { + method: 'POST', + path: '/chat/rooms/{roomId}/messages', + summary: 'Отправить сообщение', + description: 'type: TEXT|IMAGE|AUDIO|VOICE|FILE|EMOJI|POLL. isEncrypted обязателен для E2E. BOT — только через /bots/...', + auth: true + }, + { method: 'PATCH', path: '/chat/messages/{messageId}', summary: 'Редактировать сообщение', auth: true }, + { method: 'DELETE', path: '/chat/messages/{messageId}', summary: 'Удалить сообщение', auth: true }, + { method: 'POST', path: '/chat/messages/{messageId}/vote', summary: 'Голос в опросе', auth: true }, + { method: 'POST', path: '/chat/rooms/{roomId}/read', summary: 'Отметить чат прочитанным', auth: true }, + { method: 'POST', path: '/chat/rooms/{roomId}/mute', summary: 'Включить/выключить уведомления', auth: true } ] }, { @@ -152,6 +241,123 @@ export const apiReference: ApiTagGroup[] = [ { method: 'POST', path: '/media/chat/{roomId}/media/upload-url', summary: 'URL для медиа чата', auth: true } ] }, + { + tag: 'Telegram Bot API', + endpoints: [ + { + method: 'POST', + path: '/bot{token}/sendMessage', + summary: 'Отправить текстовое сообщение', + description: 'Параметры: chat_id, text, reply_markup? (inline/reply/remove).' + }, + { + method: 'POST', + path: '/bot{token}/editMessageText', + summary: 'Редактировать текст сообщения', + description: 'Параметры: chat_id, message_id, text, reply_markup?' + }, + { + method: 'POST', + path: '/bot{token}/editMessageReplyMarkup', + summary: 'Редактировать клавиатуру сообщения', + description: 'Параметры: chat_id, message_id, reply_markup' + }, + { + method: 'POST', + path: '/bot{token}/answerCallbackQuery', + summary: 'Ответ на callback_query', + description: 'Параметры: callback_query_id, text?, show_alert?, url?' + }, + { + method: 'POST', + path: '/bot{token}/sendPhoto', + summary: 'Отправить фото', + description: 'Параметры: chat_id, photo (URL или file_id), caption?, reply_markup?' + }, + { + method: 'POST', + path: '/bot{token}/sendDocument', + summary: 'Отправить документ', + description: 'Параметры: chat_id, document (URL или file_id), caption?, reply_markup?' + }, + { + method: 'POST', + path: '/bot{token}/getMe', + summary: 'Информация о боте', + description: 'Telegram-совместимый формат ответа { ok, result }. Авторизация — токен в URL.' + }, + { + method: 'POST', + path: '/bot{token}/getUpdates', + summary: 'Long polling входящих Update', + description: 'Параметры: offset, limit (до 100), timeout (до 50 сек). Недоступен при активном webhook.' + }, + { + method: 'POST', + path: '/bot{token}/setWebhook', + summary: 'Установить webhook URL', + description: 'Тело: url, secret_token?, drop_pending_updates?' + }, + { + method: 'POST', + path: '/bot{token}/deleteWebhook', + summary: 'Удалить webhook' + }, + { + method: 'POST', + path: '/bot{token}/getWebhookInfo', + summary: 'Информация о webhook' + } + ] + }, + { + tag: 'BotFather', + endpoints: [ + { method: 'GET', path: '/bots', summary: 'Список моих ботов', auth: true }, + { method: 'POST', path: '/bots', summary: 'Создать бота', description: 'Возвращает token один раз.', auth: true }, + { + method: 'GET', + path: '/bots/by-username/{botRef}/messages', + summary: 'История чата с ботом', + description: 'Сообщения пользователя с ботом в хронологическом порядке.', + auth: true + }, + { + method: 'POST', + path: '/bots/by-username/{botRef}/messages', + summary: 'Написать боту', + description: 'Inbound-сообщение пользователя → RabbitMQ → webhook/getUpdates', + auth: true + }, + { + method: 'POST', + path: '/bots/by-username/{botRef}/callback', + summary: 'Нажатие inline-кнопки', + description: 'Тело: { messageId, callbackData } → callback_query Update → webhook/getUpdates', + auth: true + }, + { method: 'GET', path: '/bots/{botId}', summary: 'Получить бота', auth: true }, + { method: 'PATCH', path: '/bots/{botId}', summary: 'Обновить name / username', auth: true }, + { method: 'DELETE', path: '/bots/{botId}', summary: 'Удалить бота', auth: true }, + { method: 'POST', path: '/bots/{botId}/revoke-token', summary: 'Перевыпустить токен', auth: true }, + { method: 'PATCH', path: '/bots/{botId}/web-app', summary: 'Настроить Mini App URL', auth: true }, + { + method: 'POST', + path: '/bots/web-app/validate', + summary: 'Проверить initData Mini App', + description: 'HMAC-SHA256 валидация как в Telegram Web Apps.' + } + ] + }, + { + tag: 'Администрирование ботов', + endpoints: [ + { method: 'GET', path: '/admin/bots', summary: 'Список всех ботов', description: 'Требует bots.manage.all', auth: true }, + { method: 'GET', path: '/admin/bots/metrics', summary: 'Метрики ботов', auth: true }, + { method: 'GET', path: '/admin/bots/{botId}', summary: 'Получить бота (админ)', auth: true }, + { method: 'PATCH', path: '/admin/bots/{botId}/active', summary: 'Заблокировать / разблокировать бота', auth: true } + ] + }, { tag: 'Уведомления', endpoints: [ diff --git a/apps/docs/lib/bot-examples.ts b/apps/docs/lib/bot-examples.ts new file mode 100644 index 0000000..d040370 --- /dev/null +++ b/apps/docs/lib/bot-examples.ts @@ -0,0 +1,186 @@ +import type { OAuthExample } from '@/lib/oauth-examples'; + +export function buildBotExamples(apiBase: string): OAuthExample[] { + const API_BASE = apiBase.replace(/\/+$/, ''); + const BOT_TOKEN = '123456789:AAHdqTcvCH1vGWJxfSeofS0As2XJarzRz5Q'; + + return [ + { + id: 'telegraf', + label: 'Telegraf', + language: 'javascript', + code: `import { Telegraf } from 'telegraf'; + +// Достаточно сменить apiRoot — код бота остаётся без изменений +const bot = new Telegraf(process.env.BOT_TOKEN, { + telegram: { + apiRoot: '${API_BASE}/bot' + } +}); + +bot.start((ctx) => ctx.reply('Привет! Бот работает через Lendry Bot API.')); +bot.command('ping', (ctx) => ctx.reply('pong')); + +// Inline-клавиатура и callback_query +bot.command('menu', (ctx) => + ctx.reply('Выберите действие:', { + reply_markup: { + inline_keyboard: [ + [{ text: '✅ OK', callback_data: 'ok' }, { text: '❌ Cancel', callback_data: 'cancel' }] + ] + } + }) +); + +bot.action('ok', async (ctx) => { + await ctx.answerCbQuery('Принято!'); + await ctx.editMessageText('Вы нажали OK ✅'); +}); + +bot.launch();` + }, + { + id: 'node-telegram-bot-api', + label: 'node-telegram-bot-api', + language: 'javascript', + code: `import TelegramBot from 'node-telegram-bot-api'; + +const token = process.env.BOT_TOKEN; +const bot = new TelegramBot(token, { + polling: true, + baseApiUrl: '${API_BASE}/bot' +}); + +bot.on('message', (msg) => { + bot.sendMessage(msg.chat.id, \`Вы написали: \${msg.text}\`); +});` + }, + { + id: 'curl', + label: 'cURL', + language: 'bash', + code: `# getMe — проверка токена +curl -s -X POST '${API_BASE}/bot${BOT_TOKEN}/getMe' | jq + +# sendMessage — chat_id = UUID пользователя Lendry ID +curl -s -X POST '${API_BASE}/bot${BOT_TOKEN}/sendMessage' \\ + -H 'Content-Type: application/json' \\ + -d '{ + "chat_id": "USER_UUID", + "text": "Привет из Bot API!" + }' | jq + +# sendMessage с inline-клавиатурой +curl -s -X POST '${API_BASE}/bot${BOT_TOKEN}/sendMessage' \\ + -H 'Content-Type: application/json' \\ + -d '{ + "chat_id": "USER_UUID", + "text": "Выберите:", + "reply_markup": { + "inline_keyboard": [[{ "text": "OK", "callback_data": "ok" }]] + } + }' | jq + +# editMessageText +curl -s -X POST '${API_BASE}/bot${BOT_TOKEN}/editMessageText' \\ + -H 'Content-Type: application/json' \\ + -d '{ + "chat_id": 1000000000000, + "message_id": 1, + "text": "Текст обновлён" + }' | jq + +# answerCallbackQuery +curl -s -X POST '${API_BASE}/bot${BOT_TOKEN}/answerCallbackQuery' \\ + -H 'Content-Type: application/json' \\ + -d '{ + "callback_query_id": "CALLBACK_QUERY_ID", + "text": "Готово!" + }' | jq + +# setChatMenuButton — глобальная кнопка меню (Web App) +curl -s -X POST '${API_BASE}/bot${BOT_TOKEN}/setChatMenuButton' \\ + -H 'Content-Type: application/json' \\ + -d '{ + "menu_button": { + "type": "web_app", + "text": "Открыть", + "web_app": { "url": "https://app.example.com" } + } + }' | jq + +# setChatMenuButton — per-chat override +curl -s -X POST '${API_BASE}/bot${BOT_TOKEN}/setChatMenuButton' \\ + -H 'Content-Type: application/json' \\ + -d '{ + "chat_id": 1000000000000, + "menu_button": { + "type": "web_app", + "text": "Персонально", + "web_app": { "url": "https://app.example.com/user" } + } + }' | jq` + }, + { + id: 'python', + label: 'Python', + language: 'python', + code: `import os +import requests + +API_BASE = '${API_BASE}' +TOKEN = os.environ['BOT_TOKEN'] +CHAT_ID = 'USER_UUID' + +def bot_api(method: str, payload: dict | None = None): + url = f"{API_BASE}/bot{TOKEN}/{method}" + response = requests.post(url, json=payload or {}, timeout=30) + response.raise_for_status() + return response.json() + +print(bot_api('getMe')) +print(bot_api('sendMessage', {'chat_id': CHAT_ID, 'text': 'Привет!'}))` + }, + { + id: 'botfather', + label: 'BotFather (REST)', + language: 'bash', + code: `# Создание бота (JWT пользователя Lendry ID) +curl -s -X POST '${API_BASE}/bots' \\ + -H 'Authorization: Bearer ACCESS_TOKEN' \\ + -H 'Content-Type: application/json' \\ + -d '{ + "name": "Мой сервисный бот", + "username": "my_service" + }' | jq + +# Ответ содержит token — сохраните его, повторно не показывается при GET + +# Профиль бота через BotFather в чате (после POST /family/groups/{id}/botfather): +# /setdescription my_service Описание перед /start +# /setabouttext my_service Краткое описание +# /setuserpic my_service https://cdn.example.com/avatar.png +# /setmenubutton my_service https://app.example.com|Открыть приложение` + }, + { + id: 'webapp', + label: 'Mini App initData', + language: 'javascript', + code: `// На backend Mini App проверяйте initData через Bot API +const response = await fetch('${API_BASE}/bots/web-app/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + initData: window.Telegram.WebApp.initData, + botToken: process.env.BOT_TOKEN + }) +}); + +const result = await response.json(); +if (!result.valid) { + throw new Error(result.error ?? 'Невалидный initData'); +} +const user = result.userJson ? JSON.parse(result.userJson) : null;` + } + ]; +} diff --git a/apps/docs/lib/docs-pages.ts b/apps/docs/lib/docs-pages.ts index 7a3e30f..2e841d7 100644 --- a/apps/docs/lib/docs-pages.ts +++ b/apps/docs/lib/docs-pages.ts @@ -7,6 +7,7 @@ export type DocBlock = | { type: 'oauth-examples' } | { type: 'auth-login-examples' } | { type: 'auth-ldap-examples' } + | { type: 'bot-examples' } | { type: 'api-reference' }; export interface DocSection { @@ -34,7 +35,7 @@ export const docPages: DocPage[] = [ blocks: [ { type: 'paragraph', - text: 'Lendry ID — enterprise Identity Provider в стиле Yandex ID: единый аккаунт, OAuth 2.0, LDAP/LDAPS, семейные группы, чат, PIN-блокировка сессий и админ-панель. Монорепозиторий состоит из NestJS-микросервисов, Go-сервисов и Next.js-приложений.' + text: 'Lendry ID — enterprise Identity Provider в стиле Yandex ID: единый аккаунт, OAuth 2.0, LDAP/LDAPS, семейные группы, чат, Telegram Bot API, PIN-блокировка сессий и админ-панель. Монорепозиторий состоит из NestJS-микросервисов, Go-сервисов и Next.js-приложений.' }, { type: 'callout', @@ -163,7 +164,8 @@ curl http://localhost:3003/docs/getting-started` 'JWT access + refresh tokens для сессий', 'PIN-код: при включении сессия создаётся с pinVerified=false', 'SystemSetting — динамические бизнес-правила без хардкода во frontend', - 'LinkedAccount — привязка LDAP, Google, Yandex и других провайдеров' + 'LinkedAccount — привязка LDAP, Google, Yandex и других провайдеров', + 'Telegram Bot API — совместимый /bot{token}/{method} на api-gateway, BotFather REST на /bots' ] } ] @@ -743,8 +745,8 @@ curl -X POST http://localhost:3000/auth/otp/verify \\ }, { slug: 'oauth', - title: 'OAuth 2.0', - description: 'Регистрация приложения, Authorization Code Flow, scopes и примеры на разных языках.', + title: 'OAuth 2.0 / OIDC', + description: 'Стандартный OpenID Connect (client_id, redirect_uri, PKCE), Discovery, token endpoint и примеры для PHP, Python, Node.js.', sections: [ { id: 'register-app', @@ -767,13 +769,29 @@ curl -X POST http://localhost:3000/auth/otp/verify \\ blocks: [ { type: 'paragraph', - text: '1) Пользователь авторизуется в IdP. 2) Ваше приложение перенаправляет на GET /oauth/authorize с clientId, redirectUri, scope, userId. 3) IdP возвращает redirectUrl с authorization code. 4) Backend обменивает code на токены через POST /oauth/token. 5) GET /oauth/userinfo возвращает профиль. Базовый URL (issuer) берётся из настройки PUBLIC_API_URL в админ-панели — примеры кода ниже подставляют его автоматически.' + text: 'Lendry ID поддерживает стандартный OpenID Connect (RFC 6749 / OIDC): client_id, redirect_uri, response_type=code, scope, state, PKCE (code_challenge). Параметр userId не требуется — IdP сам определяет пользователя после входа и экрана «Разрешить доступ».' + }, + { + type: 'list', + items: [ + '1) Ваше приложение перенаправляет браузер на GET /oauth/authorize с client_id, redirect_uri, response_type=code, scope', + '2) Пользователь входит в Lendry ID (если нужно) и подтверждает доступ', + '3) IdP делает HTTP 302 на redirect_uri?code=...&state=...', + '4) Backend обменивает code через POST /oauth/token (form-urlencoded: grant_type, client_id, client_secret, redirect_uri)', + '5) Ответ: access_token, id_token, refresh_token. Профиль: GET /oauth/userinfo' + ] }, { type: 'callout', variant: 'info', title: 'OpenID Connect Discovery', - text: 'Метаданные провайдера: GET {PUBLIC_API_URL}/.well-known/openid-configuration. В стороннем сервисе укажите issuer = PUBLIC_API_URL (например https://sso.example.ru/idp-api), а не URL authorization endpoint. Если API доступен через /idp-api на домене frontend, issuer тоже должен включать этот путь.' + text: 'Метаданные: GET {PUBLIC_API_URL}/.well-known/openid-configuration. В PHP (jumbojett/openid-connect-php), Grafana, Authentik и других OIDC-клиентах укажите issuer = PUBLIC_API_URL. Дорабатывать OidcProvider.php под нестандартные параметры не нужно.' + }, + { + type: 'callout', + variant: 'tip', + title: 'Legacy camelCase', + text: 'Для внутренних интеграций по-прежнему поддерживаются clientId, redirectUri, userId (camelCase). Внешним приложениям используйте только стандарт OIDC.' }, { type: 'callout', @@ -783,6 +801,156 @@ curl -X POST http://localhost:3000/auth/otp/verify \\ } ] }, + { + id: 'endpoints', + title: 'Endpoints и Discovery', + blocks: [ + { + type: 'paragraph', + text: 'Issuer и все endpoints берутся из PUBLIC_API_URL (настройка в админ-панели). Локально по умолчанию: http://localhost:3000. При деплое через frontend same-origin: https://ваш-домен/idp-api.' + }, + { + type: 'table', + headers: ['Endpoint', 'URL'], + rows: [ + ['Issuer / Discovery', '{PUBLIC_API_URL}/.well-known/openid-configuration'], + ['Authorization', '{PUBLIC_API_URL}/oauth/authorize'], + ['Token', '{PUBLIC_API_URL}/oauth/token'], + ['UserInfo', '{PUBLIC_API_URL}/oauth/userinfo'], + ['JWKS', '{PUBLIC_API_URL}/.well-known/jwks.json'] + ] + }, + { + type: 'callout', + variant: 'warning', + title: 'Issuer ≠ frontend URL', + text: 'В Grafana, PHP OIDC-клиентах и других интеграциях указывайте issuer = PUBLIC_API_URL (базовый URL API), а не адрес Next.js frontend (порт 3002).' + } + ] + }, + { + id: 'authorize-params', + title: 'GET /oauth/authorize — параметры', + blocks: [ + { + type: 'table', + headers: ['Параметр', 'Обязательный', 'Описание'], + rows: [ + ['client_id', 'Да', 'Client ID из админки OAuth-приложений'], + ['redirect_uri', 'Да', 'Должен точно совпадать с URI, зарегистрированным у клиента'], + ['response_type', 'Да', 'Только code'], + ['scope', 'Да', 'Например: openid profile email'], + ['state', 'Рекомендуется', 'CSRF-защита клиента'], + ['code_challenge', 'Опционально', 'PKCE S256 (публичные клиенты)'], + ['code_challenge_method', 'С PKCE', 'S256 или plain'] + ] + }, + { + type: 'code', + language: 'text', + title: 'Пример authorize URL (стандарт OIDC)', + code: `https://id.lendry.ru/idp-api/oauth/authorize + ?client_id=YOUR_CLIENT_ID + &redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback + &response_type=code + &scope=openid%20profile%20email + &state=random-state-value` + }, + { + type: 'callout', + variant: 'info', + title: 'userId не нужен', + text: 'Параметр userId (и legacy clientId/redirectUri) поддерживается только для внутренних интеграций. Внешние OIDC-клиенты (PHP OidcProvider, Grafana, Keycloak) передают только стандартные поля — IdP сам определяет пользователя после входа и экрана «Разрешить доступ».' + } + ] + }, + { + id: 'token-endpoint', + title: 'POST /oauth/token', + blocks: [ + { + type: 'paragraph', + text: 'Обмен authorization code на токены выполняется только на backend. Поддерживаются application/x-www-form-urlencoded (рекомендуется для OIDC) и JSON.' + }, + { + type: 'code', + language: 'bash', + title: 'Authorization Code Grant (form-urlencoded)', + code: `curl -X POST https://id.lendry.ru/idp-api/oauth/token \\ + -H "Content-Type: application/x-www-form-urlencoded" \\ + -d "grant_type=authorization_code" \\ + -d "code=AUTHORIZATION_CODE" \\ + -d "client_id=YOUR_CLIENT_ID" \\ + -d "client_secret=YOUR_CLIENT_SECRET" \\ + -d "redirect_uri=https://app.example.com/oauth/callback"` + }, + { + type: 'code', + language: 'json', + title: 'Ответ token endpoint (RFC 6749)', + code: `{ + "access_token": "eyJ...", + "token_type": "Bearer", + "expires_in": 900, + "refresh_token": "eyJ...", + "id_token": "eyJ..." +}` + }, + { + type: 'list', + items: [ + 'grant_type=refresh_token — обновление access token', + 'Authorization: Basic base64(client_id:client_secret) — альтернатива client_secret в теле', + 'GET /oauth/userinfo с заголовком Authorization: Bearer {access_token}' + ] + } + ] + }, + { + id: 'external-apps', + title: 'Подключение внешних приложений', + blocks: [ + { + type: 'paragraph', + text: 'Стандартные OIDC-клиенты работают без кастомизации. Укажите issuer из Discovery и зарегистрированные client_id / redirect_uri.' + }, + { + type: 'table', + headers: ['Платформа', 'Настройка'], + rows: [ + ['PHP (jumbojett/openid-connect-php)', 'new OpenIDConnectClient($issuer, $clientId, $clientSecret) — библиотека сама вызывает Discovery'], + ['Grafana', 'Auth → Generic OAuth → Auth URL / Token URL / API URL из Discovery'], + ['Authentik / Keycloak (как client)', 'Issuer URL = PUBLIC_API_URL'], + ['Любой OIDC RP', 'GET /.well-known/openid-configuration → автоконфигурация endpoints'] + ] + }, + { + type: 'code', + language: 'php', + title: 'PHP — без доработки OidcProvider', + code: `setRedirectURL('https://app.example.com/oauth/callback'); +$oidc->addScope(['openid', 'profile', 'email']); +$oidc->authenticate(); // client_id, redirect_uri, response_type=code — автоматически` + }, + { + type: 'callout', + variant: 'tip', + title: 'Redirect URI', + text: 'redirect_uri в запросе authorize и token должен байт-в-байт совпадать с одним из URI, указанных при создании OAuth-клиента в админке Lendry ID (включая протокол, путь и завершающий слэш).' + } + ] + }, { id: 'examples', title: 'Примеры интеграции', @@ -934,7 +1102,8 @@ curl -X POST http://localhost:3000/auth/otp/verify \\ { slug: 'family-chat', title: 'Семья и чат', - description: 'Семейные группы, приглашения, чат, удаление семьи и realtime через WebSocket.', + description: + 'Семейные группы, автоматические личные и бот-чаты, секретные E2E-чаты, SVG-смайлики, BotFather и realtime через WebSocket.', sections: [ { id: 'family', @@ -1001,19 +1170,1011 @@ curl -X POST http://localhost:3000/auth/otp/verify \\ ] }, { - id: 'chat', - title: 'Чат и WebSocket', + id: 'room-types', + title: 'Типы чатов семьи', + blocks: [ + { + type: 'paragraph', + text: 'При каждом запросе списка чатов (GET /chat/groups/{groupId}/rooms) сервер вызывает syncFamilyChats: автоматически создаёт недостающие личные и бот-чаты между участниками семьи и мигрирует устаревшие двухместные GROUP в DIRECT или BOT. Секретные E2E-чаты создаются только явно через POST /chat/groups/{groupId}/e2e-rooms.' + }, + { + type: 'table', + headers: ['type', 'Описание', 'Создание', 'Шифрование'], + rows: [ + ['GENERAL', 'Общий чат семьи «Общий чат»', 'При создании семьи', 'Нет'], + ['GROUP', 'Групповой чат (3+ участника)', 'POST /chat/groups/{groupId}/rooms', 'Нет'], + ['DIRECT', 'Обычный личный чат между двумя людьми', 'Автоматически при добавлении участника', 'Нет (isE2E: false)'], + ['BOT', 'Чат с Telegram-ботом участника семьи', 'Автоматически при добавлении бота в семью', 'Нет; сообщения через Bot API'], + ['E2E', 'Секретный end-to-end чат', 'POST /chat/groups/{groupId}/e2e-rooms', 'Да; isEncrypted: true обязателен'] + ] + }, + { + type: 'callout', + variant: 'info', + title: 'Поля ChatRoom', + text: 'peerUserId — id собеседника (DIRECT, BOT, E2E). botUsername — @username бота (только BOT). isE2E — true только для type: E2E. Обычные DIRECT всегда isE2E: false.' + } + ] + }, + { + id: 'chat-rest', + title: 'REST API чата', blocks: [ { type: 'list', items: [ - 'REST: /chat/groups/{groupId}/rooms, /chat/rooms/{roomId}/messages', - 'WebSocket: ws://localhost:8085/ws с JWT в query или заголовке', - 'Медиа чата: presigned upload + защищённый stream с Authorization', - 'События realtime: chat_message, chat_message_updated, chat_message_deleted, family_group_deleted' + 'GET /chat/groups/{groupId}/rooms — список чатов (с автосинхронизацией)', + 'POST /chat/groups/{groupId}/rooms — создать групповой чат (минимум 3 участника)', + 'POST /chat/groups/{groupId}/e2e-rooms — создать секретный E2E-чат', + 'GET /chat/rooms/{roomId}/messages?limit=50&beforeMessageId=... — история', + 'POST /chat/rooms/{roomId}/messages — отправить сообщение', + 'PATCH /chat/messages/{messageId} — редактировать текст', + 'DELETE /chat/messages/{messageId} — удалить сообщение', + 'POST /chat/messages/{messageId}/vote — голос в опросе', + 'POST /chat/rooms/{roomId}/read — отметить прочитанным', + 'POST /chat/rooms/{roomId}/mute — { "muted": true }', + 'POST /media/chat/{roomId}/media/upload-url — presigned URL для вложений' + ] + }, + { + type: 'code', + language: 'json', + title: 'Пример ChatRoom в ответе', + code: `{ + "id": "clx...", + "groupId": "clx...", + "type": "E2E", + "name": "Анна", + "peerUserId": "clx-peer...", + "botUsername": null, + "isE2E": true, + "unreadCount": 0, + "lastMessage": { "type": "TEXT", "content": "{\\"v\\":1,...}", "isEncrypted": true } +}` + }, + { + type: 'code', + language: 'json', + title: 'POST /chat/rooms/{roomId}/messages — тело запроса', + code: `{ + "type": "TEXT", + "content": "Привет!", + "isEncrypted": false, + "replyToId": null, + "storageKey": null, + "mimeType": null, + "metadataJson": null, + "poll": null +}` + }, + { + type: 'paragraph', + text: 'Типы сообщений: TEXT, IMAGE, AUDIO, VOICE, FILE, EMOJI, POLL. Для медиа сначала загрузите файл через upload-url, затем передайте storageKey (должен начинаться с chat/{roomId}/). Поле isEncrypted: true обязательно для комнат type: E2E.' + } + ] + }, + { + id: 'bot-family-chat', + title: 'Чаты с ботами в семье', + blocks: [ + { + type: 'paragraph', + text: 'Если участник семьи — Telegram-бот (у User есть linkedBotId), для каждого человека в семье автоматически создаётся комната type: BOT. Отправка сообщений через REST /chat/rooms/{roomId}/messages для BOT запрещена — используйте Bot API inbound.' + }, + { + type: 'list', + items: [ + 'GET /bots/by-username/{botRef}/messages — история чата с ботом (в ответе composerMenuButtonJson и composerWebAppUrl)', + 'POST /bots/by-username/{botRef}/messages — { "text": "..." } → Update боту (webhook/getUpdates)', + 'POST /bots/by-username/{botRef}/callback — { "messageId", "callbackData" } → callback_query', + 'WebSocket: bot_message, bot_message_edited, bot_callback_answer, bot_menu_button_updated' + ] + }, + { + type: 'code', + language: 'bash', + title: 'Написать боту из семейного чата', + code: `curl -s -X POST http://localhost:3000/bots/by-username/my_service_bot/messages \\ + -H "Authorization: Bearer $TOKEN" \\ + -H "Content-Type: application/json" \\ + -d '{"text":"/start"}'` + }, + { + type: 'callout', + variant: 'tip', + title: 'Mini App управления ботом', + text: 'Mini App для управления ботом открывается владельцем через шестерёнку в заголовке чата (не через кнопку меню). Глобальная кнопка меню (Web App) настраивается через BotFather (/setmenubutton) или setChatMenuButton Bot API. Legacy: PATCH /bots/{botId}/web-app с { "webAppUrl": "https://..." }. Подробнее — раздел «Telegram Bot API».' + } + ] + }, + { + id: 'botfather-family', + title: 'BotFather в семье', + blocks: [ + { + type: 'paragraph', + text: 'Системный бот BotFather можно добавить в семейную группу, чтобы создавать и управлять Telegram-ботами прямо из семейного чата (команды /newbot, /mybots, настройка профиля бота и inline-кнопки Mini App).' + }, + { + type: 'list', + items: [ + 'POST /family/groups/{groupId}/botfather — добавить BotFather в семью (JWT участника)', + 'POST /family/groups/{groupId}/bots — добавить своего или чужого бота в семью ({ "botId": "..." })', + 'После добавления появится BOT-чат с BotFather / ботом для каждого участника', + 'GET /family/groups/{groupId}/invite-search?q=... — поиск пользователей и ботов для приглашения', + 'Команды профиля в чате BotFather: /setdescription, /setabouttext, /setuserpic, /setmenubutton' ] } ] + }, + { + id: 'e2e-chat', + title: 'Секретные E2E-чаты', + blocks: [ + { + type: 'paragraph', + text: 'E2E-чаты (type: E2E) — отдельные комнаты помимо обычных DIRECT. Сервер хранит только зашифрованный ciphertext; расшифровка выполняется на клиенте. Опросы (POLL) в E2E недоступны.' + }, + { + type: 'list', + items: [ + '1. Сгенерировать ECDH P-256 key pair на клиенте (Web Crypto API)', + '2. PATCH /profile/users/{userId}/e2e-public-key — опубликовать publicKey (SPKI base64)', + '3. GET /profile/users/{peerUserId}/e2e-public-key — получить ключ собеседника', + '4. POST /chat/groups/{groupId}/e2e-rooms — { "peerUserId": "..." }', + '5. Шифровать payload и отправлять POST /chat/rooms/{roomId}/messages с isEncrypted: true' + ] + }, + { + type: 'callout', + variant: 'warning', + title: 'Приватный ключ', + text: 'Приватный ключ хранится только на клиенте (localStorage в веб-приложении). Сервер не может расшифровать сообщения. При потере ключа история E2E-чата становится нечитаемой.' + }, + { + type: 'paragraph', + text: 'Алгоритм (как в apps/frontend/lib/e2e-crypto.ts): ECDH P-256 → derive AES-256-GCM. Текстовые сообщения: JSON envelope { v: 1, iv, ciphertext } в поле content. Бинарные медиа: IV (12 байт) + ciphertext, файл загружается как application/octet-stream.' + }, + { + type: 'code', + language: 'json', + title: 'Зашифрованный payload внутри content (после расшифровки)', + code: `{ + "kind": "text", + "text": "Секретное сообщение" +} + +{ + "kind": "emoji", + "emojiId": "e-grinning" +} + +{ + "kind": "image", + "fileName": "photo.jpg", + "mimeType": "image/jpeg", + "fileSize": 102400 +}` + }, + { + type: 'code', + language: 'bash', + title: 'Создание E2E-чата', + code: `# Опубликовать свой ключ +curl -s -X PATCH http://localhost:3000/profile/users/$USER_ID/e2e-public-key \\ + -H "Authorization: Bearer $TOKEN" \\ + -H "Content-Type: application/json" \\ + -d '{"publicKey":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."}' + +# Создать секретный чат +curl -s -X POST http://localhost:3000/chat/groups/$GROUP_ID/e2e-rooms \\ + -H "Authorization: Bearer $TOKEN" \\ + -H "Content-Type: application/json" \\ + -d '{"peerUserId":"clx-peer-user-id"}'` + }, + { + type: 'code', + language: 'json', + title: 'Отправка зашифрованного текста', + code: `{ + "type": "TEXT", + "content": "{\\"v\\":1,\\"iv\\":\\"...\\",\\"ciphertext\\":\\"...\\"}", + "isEncrypted": true +}` + } + ] + }, + { + id: 'emojis', + title: 'SVG-смайлики', + blocks: [ + { + type: 'paragraph', + text: 'Вместо системных Unicode-emoji используются SVG-спрайты (как sticker packs в Telegram). Сообщение типа EMOJI хранит id смайлика в content, например e-grinning.' + }, + { + type: 'list', + items: [ + 'Отправка: POST /chat/rooms/{roomId}/messages с { "type": "EMOJI", "content": "e-grinning" }', + 'Спрайт: {FRONTEND_URL}/emojis/lendry-emojis.svg#e-grinning', + 'Каталог: 121 смайлик в 8 категориях (smileys, gestures, hearts, animals, food, travel, objects, symbols)', + 'Id формата e-{slug}, например e-thumbs-up, e-red-heart, e-pizza', + 'В E2E: зашифровать payload { "kind": "emoji", "emojiId": "e-grinning" } и отправить type: TEXT или EMOJI с isEncrypted: true' + ] + }, + { + type: 'code', + language: 'html', + title: 'Отображение смайлика в UI', + code: `` + }, + { + type: 'callout', + variant: 'info', + title: 'Справочник id', + text: 'Полный список id и категорий — apps/frontend/lib/emoji-catalog.ts (EMOJI_DEFINITIONS). Спрайт генерируется скриптом scripts/generate-emoji-sprite.mjs.' + } + ] + }, + { + id: 'chat-websocket', + title: 'WebSocket и realtime', + blocks: [ + { + type: 'list', + items: [ + 'WebSocket: ws://localhost:8085/ws с JWT в query (?token=...) или заголовке Authorization', + 'События чата: chat_message, chat_message_updated, chat_message_deleted', + 'События бота: bot_message, bot_message_edited, bot_callback_answer, bot_menu_button_updated', + 'Семья: family_group_deleted при удалении группы', + 'Медиа: presigned upload через /media/chat/{roomId}/media/upload-url, просмотр через /media/stream/{token} с Authorization' + ] + } + ] + } + ] + }, + { + slug: 'bot-api', + title: 'Telegram Bot API', + description: + 'Совместимый с Telegram Bot API прокси: Telegraf, node-telegram-bot-api, профиль бота, кнопка меню (setChatMenuButton), BotFather и Mini Apps.', + sections: [ + { + id: 'overview', + title: 'Обзор', + blocks: [ + { + type: 'paragraph', + text: 'Lendry ID включает собственный Bot API engine — drop-in замену api.telegram.org. Формат маршрутов, тела запросов и JSON-ответов совпадает с официальным Telegram Bot API, поэтому существующие боты работают без переписывания кода: достаточно указать apiRoot / baseApiUrl на ваш api-gateway.' + }, + { + type: 'callout', + variant: 'info', + title: 'Базовый URL', + text: 'Локально: http://localhost:3000/bot{token}/{method}. В production — значение PUBLIC_API_URL из админ-панели + /bot{token}/{method}. Пример: POST https://id.lendry.ru/idp-api/bot123456789:SECRET/sendMessage' + }, + { + type: 'list', + items: [ + 'BotFather (REST + чат) — регистрация ботов, профиль, перевыпуск токена, кнопка меню', + 'Telegram-совместимый endpoint — getMe, sendMessage, setChatMenuButton, клавиатуры, редактирование, callback, медиа', + 'Mini Apps — HMAC-SHA256 валидация initData как в Telegram', + 'Rate limiting через Redis и SystemSetting', + 'Админ-панель — список всех ботов, блокировка, метрики (право bots.manage.all)' + ] + } + ] + }, + { + id: 'botfather', + title: 'BotFather — регистрация бота', + blocks: [ + { + type: 'paragraph', + text: 'Любой авторизованный пользователь Lendry ID может создать до BOT_MAX_BOTS_PER_USER ботов (по умолчанию 5). Токен генерируется в формате Telegram (123456789:secret) и возвращается только при создании или revoke — в БД хранится SHA-256 hash.' + }, + { + type: 'table', + headers: ['Метод', 'Путь', 'Описание'], + rows: [ + ['GET', '/bots', 'Список ботов текущего пользователя'], + ['POST', '/bots', 'Создать бота (name, username → username_bot)'], + ['GET', '/bots/{botId}', 'Настройки бота (без полного токена, только tokenPrefix)'], + ['PATCH', '/bots/{botId}', 'Изменить name / username'], + ['DELETE', '/bots/{botId}', 'Удалить бота и все чаты/сообщения'], + ['POST', '/bots/{botId}/revoke-token', 'Перевыпустить токен'], + ['PATCH', '/bots/{botId}/web-app', 'Legacy: привязать URL Mini App (webAppUrl)'], + ['GET', '/bots/by-username/{botRef}/messages', 'История чата + composerMenuButtonJson'] + ] + }, + { + type: 'table', + headers: ['Поле BotResponse', 'Описание'], + rows: [ + ['description', 'Описание бота — показывается перед /start'], + ['aboutText', 'Краткое описание на странице профиля бота'], + ['botPicUrl', 'URL аватара бота'], + ['menuButtonJson', 'Глобальная кнопка меню (Web App) в формате Telegram JSON'], + ['webAppUrl', 'Legacy fallback для кнопки меню, если menuButton не задан'] + ] + }, + { + type: 'code', + language: 'json', + title: 'POST /bots — тело запроса', + code: `{ + "name": "Сервис уведомлений", + "username": "notify_service" +}` + }, + { + type: 'code', + language: 'json', + title: 'Ответ при создании', + code: `{ + "bot": { + "id": "uuid", + "name": "Сервис уведомлений", + "username": "notify_service_bot", + "tokenPrefix": "482910374:AbCdEf…", + "webAppUrl": null, + "description": null, + "aboutText": null, + "botPicUrl": null, + "menuButtonJson": null, + "isActive": true + }, + "token": "482910374:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789" +}` + }, + { + type: 'callout', + variant: 'warning', + title: 'Username', + text: 'Username: 5–32 символа, латиница, цифры и _. Система автоматически добавляет суффикс _bot. Передавайте имя без _bot, например my_service → my_service_bot.' + } + ] + }, + { + id: 'botfather-chat', + title: 'BotFather — команды в чате', + blocks: [ + { + type: 'paragraph', + text: 'Системный бот BotFather доступен в семейном чате (POST /family/groups/{groupId}/botfather). Помимо /newbot и /mybots поддерживается настройка профиля каждого вашего бота прямо из переписки. Username указывайте без суффикса _bot.' + }, + { + type: 'table', + headers: ['Команда', 'Пример', 'Действие'], + rows: [ + ['/setdescription', '/setdescription my_service Описание перед стартом', 'Обновляет Bot.description'], + ['/setabouttext', '/setabouttext my_service Кратко о боте', 'Обновляет Bot.aboutText'], + ['/setuserpic', '/setuserpic my_service https://cdn.example.com/avatar.png', 'Обновляет Bot.botPicUrl'], + ['/setmenubutton', '/setmenubutton my_service https://app.example.com|Открыть', 'Глобальная кнопка меню (Web App)'], + ['/setmenubutton (JSON)', '/setmenubutton my_service {"type":"web_app","text":"App","web_app":{"url":"https://..."}}', 'Точный формат Telegram menu_button'] + ] + }, + { + type: 'list', + items: [ + 'Команды доступны только владельцу бота', + 'После обновления профиля владелец получает WebSocket bot_profile_updated', + 'При смене глобальной кнопки меню все пользователи с чатами бота получают bot_menu_button_updated', + 'Mini App «Создать бота» и «Управление ботом» — через inline-кнопки и /mybots' + ] + }, + { + type: 'callout', + variant: 'info', + title: 'Формат setmenubutton', + text: 'Поддерживаются: полный JSON Telegram ({ type: "web_app", text, web_app: { url } }), голый HTTPS URL (текст кнопки «App») или URL|Текст кнопки. Для сброса глобальной кнопки используйте Bot API setChatMenuButton без chat_id и с menu_button: { "type": "default" }.' + } + ] + }, + { + id: 'telegram-endpoint', + title: 'Telegram-совместимый endpoint', + blocks: [ + { + type: 'paragraph', + text: 'Все методы принимаются по шаблону POST /bot{token}/{methodName} (также поддерживается GET с query-параметрами). Авторизация — только токен в URL, JWT не требуется.' + }, + { + type: 'table', + headers: ['Метод Bot API', 'Статус', 'Описание'], + rows: [ + ['getMe', '✓', 'Информация о боте (is_bot, username, first_name)'], + ['sendMessage', '✓', 'Отправка текста с InlineKeyboardMarkup / ReplyKeyboardMarkup / ReplyKeyboardRemove'], + ['editMessageText', '✓', 'Редактирование текста и/или reply_markup существующего сообщения'], + ['editMessageReplyMarkup', '✓', 'Изменение только клавиатуры сообщения'], + ['answerCallbackQuery', '✓', 'Ответ на нажатие inline-кнопки (toast / alert)'], + ['sendPhoto', '✓', 'Отправка фото по URL или file_id (базовая реализация)'], + ['sendDocument', '✓', 'Отправка документа по URL или file_id (базовая реализация)'], + ['setWebhook / deleteWebhook / getWebhookInfo', '✓', 'Управление webhook URL и secret_token'], + ['getUpdates', '✓', 'Long polling очередь входящих Update (offset, limit, timeout)'], + ['setChatMenuButton', '✓', 'Глобальная или per-chat кнопка меню (Web App)'] + ] + }, + { + type: 'callout', + variant: 'info', + title: 'reply_markup как строка', + text: 'Библиотеки вроде node-telegram-bot-api могут передавать reply_markup JSON-строкой вместо объекта. Движок автоматически парсит строку (включая двойное кодирование) в sendMessage, editMessageText, editMessageReplyMarkup, sendPhoto и sendDocument.' + }, + { + type: 'code', + language: 'json', + title: 'Успешный ответ sendMessage', + code: `{ + "ok": true, + "result": { + "message_id": 1, + "from": { + "id": 1234567890, + "is_bot": true, + "first_name": "Сервис уведомлений", + "username": "notify_service_bot" + }, + "chat": { + "id": 1000000000000, + "type": "private", + "first_name": "Иван", + "username": "ivan" + }, + "date": 1710000000, + "text": "Привет из Bot API!" + } +}` + }, + { + type: 'code', + language: 'json', + title: 'Ошибки (формат Telegram)', + code: `// 401 — неверный или заблокированный токен +{ "ok": false, "error_code": 401, "description": "Unauthorized" } + +// 429 — превышен лимит запросов (BOT_API_RATE_LIMIT_PER_SECOND) +{ + "ok": false, + "error_code": 429, + "description": "Too Many Requests: retry later", + "parameters": { "retry_after": 1 } +}` + }, + { + type: 'callout', + variant: 'tip', + title: 'chat_id', + text: 'Для первого сообщения пользователю передайте chat_id = UUID пользователя Lendry ID. Система создаст BotChat и вернёт numeric chat.id в ответе — его можно использовать в последующих sendMessage, editMessageText и editMessageReplyMarkup.' + } + ] + }, + { + id: 'menu-button', + title: 'Кнопка меню (Menu Button / Web App)', + blocks: [ + { + type: 'paragraph', + text: 'Кнопка меню отображается слева от поля ввода в чате с ботом (как в Telegram). Конфигурация хранится в Bot.menuButton (глобально) и в ChatMenuButton (переопределение для конкретного пользователя/чата). Frontend разрешает иерархию: локальный override → глобальный menuButton → legacy webAppUrl → BotFather create URL.' + }, + { + type: 'table', + headers: ['Источник', 'Когда используется'], + rows: [ + ['ChatMenuButton (per-chat)', 'setChatMenuButton с chat_id — приоритет для этого пользователя'], + ['Bot.menuButton (глобально)', 'setChatMenuButton без chat_id или /setmenubutton в BotFather'], + ['Bot.webAppUrl (legacy)', 'Если menuButton не задан; bot-manage URL исключается из composer'], + ['BotFather (системный бот)', 'Mini App «Создать бота» для @BotFather_bot'] + ] + }, + { + type: 'code', + language: 'json', + title: 'Схема menu_button (Telegram)', + code: `{ + "type": "web_app", + "text": "Открыть приложение", + "web_app": { "url": "https://app.example.com/mini" } +} + +// Сброс per-chat override (вернуться к глобальной): +{ "type": "default" }` + }, + { + type: 'code', + language: 'json', + title: 'POST /bot{token}/setChatMenuButton — глобальная кнопка', + code: `POST /bot{token}/setChatMenuButton +{ + "menu_button": { + "type": "web_app", + "text": "Каталог", + "web_app": { "url": "https://shop.example.com/webapp" } + } +} + +// Ответ: +{ "ok": true, "result": true }` + }, + { + type: 'code', + language: 'json', + title: 'Per-chat override', + code: `POST /bot{token}/setChatMenuButton +{ + "chat_id": 1000000000000, + "menu_button": { + "type": "web_app", + "text": "Персональное меню", + "web_app": { "url": "https://app.example.com/user/123" } + } +} + +// Сброс override для чата (вернуть глобальную кнопку): +{ + "chat_id": 1000000000000, + "menu_button": { "type": "default" } +} +// или omit menu_button` + }, + { + type: 'code', + language: 'json', + title: 'GET /bots/by-username/{botRef}/messages — фрагмент ответа', + code: `{ + "botUsername": "my_service_bot", + "botDisplayName": "Мой сервис", + "composerWebAppUrl": "https://app.example.com/webapp", + "composerMenuButtonJson": "{\\"type\\":\\"web_app\\",\\"text\\":\\"Каталог\\",\\"web_app\\":{\\"url\\":\\"https://app.example.com/webapp\\"}}", + "manageWebAppUrl": "/mini-apps/bot-manage?botId=..." +}` + }, + { + type: 'callout', + variant: 'tip', + title: 'Realtime', + text: 'После setChatMenuButton клиент получает WebSocket bot_menu_button_updated с composerMenuButtonJson и composerWebAppUrl — поле ввода перерисовывается без перезагрузки страницы.' + } + ] + }, + { + id: 'keyboards', + title: 'Клавиатуры (Reply и Inline)', + blocks: [ + { + type: 'paragraph', + text: 'Параметр reply_markup в sendMessage, editMessageText и editMessageReplyMarkup принимает те же объекты, что и официальный Telegram Bot API. Движок нормализует их во внутренний формат для рендера в мессенджере Lendry ID и сохраняет оригинальный Telegram JSON для совместимости с библиотеками.' + }, + { + type: 'table', + headers: ['Telegram reply_markup', 'Назначение'], + rows: [ + ['InlineKeyboardMarkup', 'Кнопки под сообщением: callback_data, url, web_app'], + ['ReplyKeyboardMarkup', 'Пользовательская клавиатура вместо стандартной (resize_keyboard, one_time_keyboard)'], + ['ReplyKeyboardRemove', 'Скрыть reply-клавиатуру у пользователя'] + ] + }, + { + type: 'code', + language: 'json', + title: 'sendMessage с inline-клавиатурой', + code: `POST /bot{token}/sendMessage +{ + "chat_id": "USER_UUID", + "text": "Выберите действие:", + "reply_markup": { + "inline_keyboard": [ + [ + { "text": "✅ Подтвердить", "callback_data": "confirm" }, + { "text": "❌ Отмена", "callback_data": "cancel" } + ], + [ + { "text": "Открыть сайт", "url": "https://lendry.ru" }, + { "text": "Mini App", "web_app": { "url": "https://app.example.com" } } + ] + ] + } +}` + }, + { + type: 'code', + language: 'json', + title: 'sendMessage с reply-клавиатурой', + code: `{ + "chat_id": "1000000000000", + "text": "Отправьте контакт или выберите пункт меню:", + "reply_markup": { + "keyboard": [ + [{ "text": "📞 Отправить телефон", "request_contact": true }], + [{ "text": "Меню" }, { "text": "Помощь" }] + ], + "resize_keyboard": true, + "one_time_keyboard": true, + "input_field_placeholder": "Введите сообщение…" + } +}` + }, + { + type: 'code', + language: 'json', + title: 'Внутренний формат (replyMarkup в WebSocket-событиях)', + code: `// kind: "inline" — кнопки с callbackData / url / webAppUrl +{ + "kind": "inline", + "rows": [ + [ + { "text": "✅ Подтвердить", "callbackData": "confirm" }, + { "text": "❌ Отмена", "callbackData": "cancel" } + ] + ] +} + +// kind: "reply" — пользовательская клавиатура +{ + "kind": "reply", + "rows": [[{ "text": "Меню" }]], + "resizeKeyboard": true, + "oneTimeKeyboard": true +} + +// kind: "remove" +{ "kind": "remove" }` + } + ] + }, + { + id: 'edit-messages', + title: 'Редактирование сообщений', + blocks: [ + { + type: 'paragraph', + text: 'Бот может обновлять уже отправленные сообщения. Система находит запись в BotMessage по chat_id и message_id, обновляет текст и/или reply_markup, выставляет editedAt и мгновенно уведомляет клиент мессенджера через WebSocket (событие bot_message_edited).' + }, + { + type: 'table', + headers: ['Метод', 'Обязательные параметры', 'Описание'], + rows: [ + ['editMessageText', 'chat_id, message_id, text', 'Изменить текст; reply_markup опционален'], + ['editMessageReplyMarkup', 'chat_id, message_id, reply_markup', 'Изменить только клавиатуру'] + ] + }, + { + type: 'code', + language: 'json', + title: 'editMessageText', + code: `POST /bot{token}/editMessageText +{ + "chat_id": 1000000000000, + "message_id": 42, + "text": "Статус обновлён ✅", + "reply_markup": { + "inline_keyboard": [ + [{ "text": "Обновить снова", "callback_data": "refresh" }] + ] + } +}` + }, + { + type: 'code', + language: 'json', + title: 'Ответ (стандартный Telegram Message с edit_date)', + code: `{ + "ok": true, + "result": { + "message_id": 42, + "from": { "id": 1234567890, "is_bot": true, "first_name": "Мой бот", "username": "my_bot" }, + "chat": { "id": 1000000000000, "type": "private", "first_name": "Иван" }, + "date": 1710000000, + "edit_date": 1710003600, + "text": "Статус обновлён ✅", + "reply_markup": { + "inline_keyboard": [[{ "text": "Обновить снова", "callback_data": "refresh" }]] + } + } +}` + }, + { + type: 'code', + language: 'json', + title: 'Ошибка — сообщение не найдено', + code: `{ "ok": false, "error_code": 400, "description": "Bad Request: message to edit not found" }` + } + ] + }, + { + id: 'callback-queries', + title: 'Callback Query (inline-кнопки)', + blocks: [ + { + type: 'paragraph', + text: 'При нажатии inline-кнопки в мессенджере Lendry ID frontend отправляет событие на backend. Bot Engine формирует стандартный Telegram Update с callback_query и доставляет его боту через webhook или getUpdates. Бот отвечает методом answerCallbackQuery — клиент получает toast или alert.' + }, + { + type: 'list', + items: [ + 'Inbound: POST /bots/by-username/{botRef}/callback — JWT пользователя, тело { messageId, callbackData }', + 'Delivery: RabbitMQ → сериализация в callback_query Update → webhook / long polling', + 'Outbound: POST /bot{token}/answerCallbackQuery — callback_query_id, text?, show_alert?, url?', + 'Realtime: событие bot_callback_answer — снятие loading-состояния кнопки и показ уведомления' + ] + }, + { + type: 'code', + language: 'json', + title: 'POST /bots/by-username/my_service_bot/callback', + code: `Authorization: Bearer ACCESS_TOKEN +{ + "messageId": 42, + "callbackData": "confirm" +}` + }, + { + type: 'code', + language: 'json', + title: 'Telegram Update (callback_query) — получает бот', + code: `{ + "update_id": 100002, + "callback_query": { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "from": { "id": 1000000000000, "is_bot": false, "first_name": "Иван", "username": "ivan" }, + "message": { + "message_id": 42, + "from": { "id": 1234567890, "is_bot": true, "first_name": "Мой бот", "username": "my_bot" }, + "chat": { "id": 1000000000000, "type": "private", "first_name": "Иван" }, + "date": 1710000000, + "text": "Выберите действие:", + "reply_markup": { + "inline_keyboard": [[{ "text": "✅ Подтвердить", "callback_data": "confirm" }]] + } + }, + "chat_instance": "bot-uuid:user-uuid", + "data": "confirm" + } +}` + }, + { + type: 'code', + language: 'json', + title: 'answerCallbackQuery — ответ бота', + code: `POST /bot{token}/answerCallbackQuery +{ + "callback_query_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "text": "Действие выполнено!", + "show_alert": false +} + +// Ответ: +{ "ok": true, "result": true }` + }, + { + type: 'callout', + variant: 'info', + title: 'Telegraf', + text: 'В Telegraf обработчик bot.action("confirm", ...) и bot.on("callback_query", ...) работают без изменений — достаточно указать apiRoot на Lendry Bot API.' + } + ] + }, + { + id: 'media', + title: 'Медиа (sendPhoto / sendDocument)', + blocks: [ + { + type: 'paragraph', + text: 'Базовая поддержка отправки медиа: параметр photo или document — строка с публичным URL или file_id. Файл сохраняется в mediaUrl сообщения; клиент мессенджера получает событие bot_message с messageType photo или document. Загрузка multipart/form-data напрямую в MinIO — в roadmap.' + }, + { + type: 'code', + language: 'json', + title: 'sendPhoto', + code: `POST /bot{token}/sendPhoto +{ + "chat_id": "USER_UUID", + "photo": "https://cdn.example.com/image.jpg", + "caption": "Скриншот отчёта", + "reply_markup": { + "inline_keyboard": [[{ "text": "Скачать PDF", "callback_data": "download_pdf" }]] + } +}` + }, + { + type: 'code', + language: 'json', + title: 'sendDocument', + code: `POST /bot{token}/sendDocument +{ + "chat_id": 1000000000000, + "document": "https://cdn.example.com/report.pdf", + "caption": "Ежемесячный отчёт" +}` + }, + { + type: 'code', + language: 'json', + title: 'Фрагмент ответа sendPhoto', + code: `{ + "ok": true, + "result": { + "message_id": 43, + "photo": [{ "file_id": "https://cdn.example.com/image.jpg", "width": 320, "height": 240 }], + "caption": "Скриншот отчёта", + "reply_markup": { "inline_keyboard": [[{ "text": "Скачать PDF", "callback_data": "download_pdf" }]] } + } +}` + } + ] + }, + { + id: 'realtime-events', + title: 'Realtime-события мессенджера', + blocks: [ + { + type: 'paragraph', + text: 'Исходящие сообщения и интерактивные обновления бота доставляются пользователю через WebSocket (RabbitMQ → notification service). Frontend мессенджера подписывается на эти события для рендера UI.' + }, + { + type: 'table', + headers: ['Событие', 'Когда', 'Ключевые поля payload'], + rows: [ + ['bot_message', 'sendMessage / sendPhoto / sendDocument', 'messageId, text, messageType, mediaUrl, replyMarkup, telegramReplyMarkup'], + ['bot_message_edited', 'editMessageText / editMessageReplyMarkup', 'messageId, text, replyMarkup, editedAt'], + ['bot_callback_answer', 'answerCallbackQuery', 'callbackQueryId, text, showAlert, url'], + ['bot_menu_button_updated', 'setChatMenuButton / BotFather /setmenubutton', 'composerMenuButtonJson, composerWebAppUrl, buttonText'], + ['bot_profile_updated', 'BotFather /setdescription и др.', 'description, aboutText, botPicUrl, menuButtonJson'] + ] + }, + { + type: 'code', + language: 'json', + title: 'Пример payload bot_menu_button_updated', + code: `{ + "botId": "uuid", + "botUsername": "my_service_bot", + "composerWebAppUrl": "https://app.example.com/webapp", + "composerMenuButtonJson": "{\\"type\\":\\"web_app\\",\\"text\\":\\"Каталог\\",\\"web_app\\":{\\"url\\":\\"https://app.example.com/webapp\\"}}", + "buttonText": "Каталог" +}` + }, + { + type: 'code', + language: 'json', + title: 'Пример payload bot_message', + code: `{ + "botId": "uuid", + "botUsername": "my_service_bot", + "chatId": "1000000000000", + "messageId": 42, + "text": "Выберите действие:", + "messageType": "text", + "mediaUrl": null, + "replyMarkup": { + "kind": "inline", + "rows": [[{ "text": "✅ Подтвердить", "callbackData": "confirm" }]] + }, + "telegramReplyMarkup": { + "inline_keyboard": [[{ "text": "✅ Подтвердить", "callback_data": "confirm" }]] + } +}` + } + ] + }, + { + id: 'inbound', + title: 'Входящие сообщения (Webhook / Long Polling)', + blocks: [ + { + type: 'paragraph', + text: 'Когда пользователь Lendry ID пишет боту, событие попадает в RabbitMQ (очередь chat.message.bot_inbound). Bot Engine сериализует его в Telegram Update и доставляет на webhook или в очередь getUpdates.' + }, + { + type: 'list', + items: [ + 'POST /bots/by-username/{botRef}/messages — отправить сообщение боту (JWT пользователя)', + 'POST /bots/by-username/{botRef}/callback — нажатие inline-кнопки → callback_query Update', + 'Webhook: POST на ваш URL с телом Update; заголовок X-Telegram-Bot-Api-Secret-Token при secret_token', + 'Long polling: getUpdates без webhook; timeout до 50 секунд', + 'Realtime fallback: если RabbitMQ недоступен, доставка выполняется синхронно' + ] + }, + { + type: 'code', + language: 'json', + title: 'Telegram Update (входящее сообщение)', + code: `{ + "update_id": 100001, + "message": { + "message_id": 1, + "from": { "id": 1000000000000, "is_bot": false, "first_name": "Иван", "username": "ivan" }, + "chat": { "id": 1000000000000, "type": "private", "first_name": "Иван" }, + "date": 1710000000, + "text": "Привет!" + } +} + +// Callback query — см. раздел «Callback Query»` + } + ] + }, + { + id: 'mini-apps', + title: 'Mini Apps (Web Apps)', + blocks: [ + { + type: 'paragraph', + text: 'Mini App можно привязать тремя способами: (1) глобальная кнопка меню через menuButton / setChatMenuButton — рекомендуемый способ; (2) legacy webAppUrl через PATCH /bots/{botId}/web-app; (3) inline-кнопка web_app в reply_markup сообщения. Для проверки подлинности пользователя на backend используйте HMAC-SHA256 над initData с секретом HMAC_SHA256("WebAppData", bot_token) — как в Telegram.' + }, + { + type: 'code', + language: 'bash', + title: 'POST /bots/web-app/validate', + code: `curl -s -X POST http://localhost:3000/bots/web-app/validate \\ + -H 'Content-Type: application/json' \\ + -d '{ + "initData": "query_id=...&user=%7B%22id%22%3A123%7D&auth_date=1710000000&hash=...", + "botToken": "YOUR_BOT_TOKEN" + }'` + }, + { + type: 'code', + language: 'json', + title: 'Ответ при успешной проверке', + code: `{ + "valid": true, + "userJson": "{\\"id\\":123,\\"first_name\\":\\"Иван\\"}", + "authDate": "1710000000" +}` + } + ] + }, + { + id: 'limits', + title: 'Лимиты и безопасность', + blocks: [ + { + type: 'table', + headers: ['SystemSetting', 'По умолчанию', 'Назначение'], + rows: [ + ['BOT_MAX_BOTS_PER_USER', '5', 'Максимум ботов на одного пользователя'], + ['BOT_API_RATE_LIMIT_PER_SECOND', '30', 'Запросов Bot API на токен в секунду (Redis)'] + ] + }, + { + type: 'list', + items: [ + 'Токен бота хранится как SHA-256 hash; кеш валидации — Redis (TTL 5 мин)', + 'При revoke или блокировке кеш токена инвалидируется', + 'Сообщения бота доставляются через WebSocket: bot_message, bot_message_edited, bot_callback_answer', + 'callback_query_id хранится в Redis с TTL для answerCallbackQuery', + 'Администраторы с правом bots.manage.all могут просматривать все боты и блокировать злоупотребления' + ] + } + ] + }, + { + id: 'admin', + title: 'Администрирование ботов', + blocks: [ + { + type: 'paragraph', + text: 'Endpoints ниже требуют JWT администратора и право bots.manage.all (входит в системную роль admin).' + }, + { + type: 'list', + items: [ + 'GET /admin/bots — список всех ботов платформы (?search, ?page, ?limit)', + 'GET /admin/bots/metrics — totalBots, activeBots, blockedBots, totalMessages, totalChats', + 'GET /admin/bots/{botId} — карточка любого бота', + 'PATCH /admin/bots/{botId}/active — { "isActive": false } для блокировки Bot API' + ] + } + ] + }, + { + id: 'examples', + title: 'Примеры интеграции', + blocks: [{ type: 'bot-examples' }] } ] }, diff --git a/apps/docs/lib/navigation.ts b/apps/docs/lib/navigation.ts index 38bb2d0..48e2de2 100644 --- a/apps/docs/lib/navigation.ts +++ b/apps/docs/lib/navigation.ts @@ -9,10 +9,11 @@ export const docNavigation: DocNavItem[] = [ { slug: 'architecture', title: 'Архитектура', group: 'Введение' }, { slug: 'deployment', title: 'Развёртывание на сервере', group: 'Введение' }, { slug: 'authentication', title: 'Аутентификация', group: 'Интеграция' }, - { slug: 'oauth', title: 'OAuth 2.0', group: 'Интеграция' }, + { slug: 'oauth', title: 'OAuth 2.0 / OIDC', group: 'Интеграция' }, { slug: 'ldap', title: 'LDAP / LDAPS', group: 'Интеграция' }, { slug: 'sessions', title: 'Сессии, PIN и удаление аккаунта', group: 'Безопасность' }, { slug: 'family-chat', title: 'Семья и чат', group: 'Функции' }, + { slug: 'bot-api', title: 'Telegram Bot API', group: 'Интеграция' }, { slug: 'api-reference', title: 'Справочник API', group: 'Справочник' } ]; diff --git a/apps/docs/lib/oauth-examples.ts b/apps/docs/lib/oauth-examples.ts index 80f4dfd..cf55b3b 100644 --- a/apps/docs/lib/oauth-examples.ts +++ b/apps/docs/lib/oauth-examples.ts @@ -13,7 +13,7 @@ export function buildOAuthExamples(apiBase: string): OAuthExample[] { id: 'javascript', label: 'JavaScript', language: 'javascript', - code: `// Authorization Code Flow (Node.js / браузер) + code: `// Authorization Code Flow — стандартный OIDC (RFC 6749) const clientId = 'YOUR_CLIENT_ID'; const redirectUri = 'https://app.example.com/oauth/callback'; const scope = 'openid profile email'; @@ -21,34 +21,63 @@ const state = crypto.randomUUID(); // Шаг 1: перенаправить пользователя на IdP const authorizeUrl = new URL('${API_BASE}/oauth/authorize'); -authorizeUrl.searchParams.set('userId', 'USER_ID_AFTER_LOGIN'); -authorizeUrl.searchParams.set('clientId', clientId); -authorizeUrl.searchParams.set('redirectUri', redirectUri); +authorizeUrl.searchParams.set('client_id', clientId); +authorizeUrl.searchParams.set('redirect_uri', redirectUri); +authorizeUrl.searchParams.set('response_type', 'code'); authorizeUrl.searchParams.set('scope', scope); authorizeUrl.searchParams.set('state', state); window.location.href = authorizeUrl.toString(); -// OIDC Discovery (issuer = PUBLIC_API_URL из настроек админки) +// OIDC Discovery // GET ${API_BASE}/.well-known/openid-configuration // Шаг 2: обменять code на токены (на backend!) const tokenResponse = await fetch('${API_BASE}/oauth/token', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grantType: 'authorization_code', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', code: 'AUTHORIZATION_CODE', - clientId, - clientSecret: 'YOUR_CLIENT_SECRET', - redirectUri + client_id: clientId, + client_secret: 'YOUR_CLIENT_SECRET', + redirect_uri: redirectUri }) }); const tokens = await tokenResponse.json(); +// tokens.access_token, tokens.id_token, tokens.refresh_token // Шаг 3: получить профиль const profile = await fetch('${API_BASE}/oauth/userinfo', { - headers: { Authorization: \`Bearer \${tokens.accessToken}\` } + headers: { Authorization: \`Bearer \${tokens.access_token}\` } }).then((r) => r.json());` + }, + { + id: 'php', + label: 'PHP (OIDC)', + language: 'php', + code: `setRedirectURL($redirectUri); +$oidc->addScope(['openid', 'profile', 'email']); + +// Библиотека сама использует discovery, client_id, redirect_uri, response_type=code +$oidc->authenticate(); + +$sub = $oidc->requestUserInfo('sub'); +$name = $oidc->requestUserInfo('name'); +$email = $oidc->requestUserInfo('email'); + +// userId передавать НЕ нужно — IdP определяет пользователя после входа` }, { id: 'typescript-next', @@ -65,19 +94,19 @@ export async function GET(request: NextRequest) { const tokenRes = await fetch(\`\${ISSUER}/oauth/token\`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grantType: 'authorization_code', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', code, - clientId: process.env.OAUTH_CLIENT_ID, - clientSecret: process.env.OAUTH_CLIENT_SECRET, - redirectUri: process.env.OAUTH_REDIRECT_URI + client_id: process.env.OAUTH_CLIENT_ID!, + client_secret: process.env.OAUTH_CLIENT_SECRET!, + redirect_uri: process.env.OAUTH_REDIRECT_URI! }) }); const tokens = await tokenRes.json(); const response = NextResponse.redirect('/dashboard'); - response.cookies.set('access_token', tokens.accessToken, { httpOnly: true, secure: true }); + response.cookies.set('access_token', tokens.access_token, { httpOnly: true, secure: true }); return response; }` }, @@ -86,125 +115,61 @@ export async function GET(request: NextRequest) { label: 'Python', language: 'python', code: `import requests -from urllib.parse import urlencode API_BASE = '${API_BASE}' CLIENT_ID = 'YOUR_CLIENT_ID' CLIENT_SECRET = 'YOUR_CLIENT_SECRET' REDIRECT_URI = 'https://app.example.com/oauth/callback' -# OIDC Discovery discovery = requests.get(f'{API_BASE}/.well-known/openid-configuration', timeout=15).json() -params = urlencode({ - 'userId': 'USER_ID', - 'clientId': CLIENT_ID, - 'redirectUri': REDIRECT_URI, - 'scope': 'openid profile email', - 'state': 'random-state' -}) -authorize_url = f'{API_BASE}/oauth/authorize?{params}' +# Authorization URL — стандартные параметры, userId не нужен +authorize_url = ( + f"{discovery['authorization_endpoint']}" + f"?client_id={CLIENT_ID}" + f"&redirect_uri={requests.utils.quote(REDIRECT_URI, safe='')}" + f"&response_type=code" + f"&scope=openid%20profile%20email" + f"&state=random-state" +) -token_response = requests.post(f'{API_BASE}/oauth/token', json={ - 'grantType': 'authorization_code', - 'code': 'AUTHORIZATION_CODE', - 'clientId': CLIENT_ID, - 'clientSecret': CLIENT_SECRET, - 'redirectUri': REDIRECT_URI -}, timeout=15) +token_response = requests.post( + discovery['token_endpoint'], + data={ + 'grant_type': 'authorization_code', + 'code': 'AUTHORIZATION_CODE', + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + 'redirect_uri': REDIRECT_URI, + }, + timeout=15, +) tokens = token_response.json() profile = requests.get( - f'{API_BASE}/oauth/userinfo', - headers={'Authorization': f"Bearer {tokens['accessToken']}"}, - timeout=15 + discovery['userinfo_endpoint'], + headers={'Authorization': f"Bearer {tokens['access_token']}"}, + timeout=15, ).json()` - }, - { - id: 'php', - label: 'PHP', - language: 'php', - code: ` 'USER_ID', - 'clientId' => $clientId, - 'redirectUri' => $redirectUri, - 'scope' => 'openid profile email', - 'state' => bin2hex(random_bytes(16)), -]); -header('Location: ' . $apiBase . '/oauth/authorize?' . $params); -exit;` - }, - { - id: 'go', - label: 'Go', - language: 'go', - code: `package main - -import ( - "bytes" - "encoding/json" - "net/http" - "net/url" -) - -const apiBase = "${API_BASE}" - -func buildAuthorizeURL(userID, clientID, redirectURI, scope, state string) string { - q := url.Values{} - q.Set("userId", userID) - q.Set("clientId", clientID) - q.Set("redirectUri", redirectURI) - q.Set("scope", scope) - q.Set("state", state) - return apiBase + "/oauth/authorize?" + q.Encode() -}` - }, - { - id: 'csharp', - label: 'C#', - language: 'csharp', - code: `using System.Net.Http.Json; - -var apiBase = "${API_BASE}"; -var clientId = Environment.GetEnvironmentVariable("OAUTH_CLIENT_ID"); -var redirectUri = "https://app.example.com/oauth/callback"; - -var discovery = await new HttpClient().GetFromJsonAsync>( - $"{apiBase}/.well-known/openid-configuration"); - -var authorizeUrl = - $"{apiBase}/oauth/authorize?userId=USER_ID&clientId={clientId}" + - $"&redirectUri={Uri.EscapeDataString(redirectUri)}&scope=openid profile email&state=xyz";` }, { id: 'curl', label: 'cURL', language: 'bash', - code: `# OIDC Discovery (issuer = ${API_BASE}) + code: `# OIDC Discovery curl ${API_BASE}/.well-known/openid-configuration -# Authorization (браузер пользователя) -open "${API_BASE}/oauth/authorize?userId=USER_ID&clientId=CLIENT_ID&redirectUri=https%3A%2F%2Fapp.example.com%2Fcallback&scope=openid%20profile%20email&state=xyz" +# Authorization (браузер пользователя, стандартный OIDC) +open "${API_BASE}/oauth/authorize?client_id=CLIENT_ID&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&response_type=code&scope=openid%20profile%20email&state=xyz" -# Обмен code на токены +# Обмен code на токены (form-urlencoded) curl -X POST ${API_BASE}/oauth/token \\ - -H "Content-Type: application/json" \\ - -d '{ - "grantType": "authorization_code", - "code": "AUTHORIZATION_CODE", - "clientId": "CLIENT_ID", - "clientSecret": "CLIENT_SECRET", - "redirectUri": "https://app.example.com/callback" - }' + -H "Content-Type: application/x-www-form-urlencoded" \\ + -d "grant_type=authorization_code" \\ + -d "code=AUTHORIZATION_CODE" \\ + -d "client_id=CLIENT_ID" \\ + -d "client_secret=CLIENT_SECRET" \\ + -d "redirect_uri=https://app.example.com/callback" # UserInfo curl ${API_BASE}/oauth/userinfo \\ diff --git a/apps/docs/lib/oauth-url.ts b/apps/docs/lib/oauth-url.ts index 5312efe..eebd389 100644 --- a/apps/docs/lib/oauth-url.ts +++ b/apps/docs/lib/oauth-url.ts @@ -39,3 +39,18 @@ export function buildOAuthEndpoints(apiBase: string): OAuthEndpoints { jwksUrl: `${base}/.well-known/jwks.json` }; } + +export function buildAuthorizeUrl( + apiBase: string, + params: { clientId: string; redirectUri: string; scope: string; state?: string; codeChallenge?: string; codeChallengeMethod?: string } +) { + const url = new URL(`${normalizeBaseUrl(apiBase)}/oauth/authorize`); + url.searchParams.set('client_id', params.clientId); + url.searchParams.set('redirect_uri', params.redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', params.scope); + if (params.state) url.searchParams.set('state', params.state); + if (params.codeChallenge) url.searchParams.set('code_challenge', params.codeChallenge); + if (params.codeChallengeMethod) url.searchParams.set('code_challenge_method', params.codeChallengeMethod); + return url.toString(); +} diff --git a/apps/frontend/app/admin/bots/page.tsx b/apps/frontend/app/admin/bots/page.tsx new file mode 100644 index 0000000..11f0183 --- /dev/null +++ b/apps/frontend/app/admin/bots/page.tsx @@ -0,0 +1,234 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Ban, Bot, CheckCircle2, Loader2, MessageSquare, Search, Users } from 'lucide-react'; +import { AdminShell } from '@/components/id/admin-shell'; +import { useAuth } from '@/components/id/auth-provider'; +import { useToast } from '@/components/id/toast-provider'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { + AdminBotMetrics, + ManagedBot, + fetchAdminBotMetrics, + fetchAdminBots, + setAdminBotActive +} from '@/lib/api'; +import { getAdminLandingPath } from '@/lib/admin-access'; + +export default function AdminBotsPage() { + const router = useRouter(); + const { token, user: currentUser } = useAuth(); + const { showToast } = useToast(); + const [bots, setBots] = useState([]); + const [total, setTotal] = useState(0); + const [metrics, setMetrics] = useState(null); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [actionBotId, setActionBotId] = useState(null); + + const loadBots = useCallback(async () => { + if (!token) return; + setLoading(true); + try { + const [botsResponse, metricsResponse] = await Promise.all([ + fetchAdminBots({ search, page, limit: 20 }, token), + fetchAdminBotMetrics(token) + ]); + setBots(botsResponse.bots ?? []); + setTotal(botsResponse.total ?? 0); + setMetrics(metricsResponse); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Не удалось загрузить ботов'); + } finally { + setLoading(false); + } + }, [page, search, showToast, token]); + + useEffect(() => { + if (!currentUser) return; + if (!currentUser.canManageBots && !currentUser.isSuperAdmin) { + router.replace(getAdminLandingPath(currentUser)); + } + }, [currentUser, router]); + + useEffect(() => { + if (!currentUser?.canManageBots && !currentUser?.isSuperAdmin) return; + void loadBots(); + }, [currentUser?.canManageBots, currentUser?.isSuperAdmin, loadBots]); + + async function handleToggleActive(bot: ManagedBot) { + if (!token) return; + setActionBotId(bot.id); + try { + await setAdminBotActive(bot.id, !bot.isActive, token); + showToast(bot.isActive ? 'Бот заблокирован' : 'Бот разблокирован'); + await loadBots(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Не удалось изменить статус бота'); + } finally { + setActionBotId(null); + } + } + + const totalPages = Math.max(1, Math.ceil(total / 20)); + + return ( + +

Telegram-боты

+
+ + + + +
+ +
+
+ + { + setSearch(event.target.value); + setPage(1); + }} + /> +
+

Найдено: {total}

+
+ +
+ + + + Бот + Владелец + Статус + Действия + + + + {loading ? ( + + + + + + ) : bots.length ? ( + bots.map((bot) => ( + + +
+
+ +
+
+

{bot.name}

+

@{bot.username}

+ {bot.isSystemBot ?

Системный

: null} +
+
+
+ +
+ +
+

{bot.owner?.displayName ?? '—'}

+ {bot.owner?.username ?

@{bot.owner.username}

: null} +
+
+
+ + + {bot.isActive ? 'Активен' : 'Заблокирован'} + + + + {!bot.isSystemBot ? ( + + ) : ( + + )} + +
+ )) + ) : ( + + + Боты не найдены + + + )} +
+
+
+ + {totalPages > 1 ? ( +
+ + + {page} / {totalPages} + + +
+ ) : null} +
+ ); +} + +function MetricCard({ + label, + value, + icon: Icon, + accent +}: { + label: string; + value: number; + icon: typeof Bot; + accent?: string; +}) { + return ( +
+
+ + {label} +
+

{value.toLocaleString('ru-RU')}

+
+ ); +} diff --git a/apps/frontend/app/auth/login/page.tsx b/apps/frontend/app/auth/login/page.tsx index 509bccd..db7a2ef 100644 --- a/apps/frontend/app/auth/login/page.tsx +++ b/apps/frontend/app/auth/login/page.tsx @@ -82,6 +82,16 @@ export default function LoginPage() { const router = useRouter(); + const finishLoginRedirect = useCallback(() => { + const redirectAfterLogin = + typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('redirect') : null; + if (redirectAfterLogin && redirectAfterLogin.startsWith('/')) { + router.push(redirectAfterLogin); + return; + } + router.push('/'); + }, [router]); + const { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, beginTotpLogin, verifyTotpLogin, completePin, applyLoginAuth } = useAuth(); const { showToast } = useToast(); @@ -174,7 +184,7 @@ export default function LoginPage() { } else { - router.push('/'); + finishLoginRedirect(); } @@ -596,7 +606,7 @@ export default function LoginPage() { await completePin(pendingSessionId, pin); - router.push('/'); + finishLoginRedirect(); } catch (error) { diff --git a/apps/frontend/app/auth/oauth/authorize/page.tsx b/apps/frontend/app/auth/oauth/authorize/page.tsx new file mode 100644 index 0000000..1e4a5b1 --- /dev/null +++ b/apps/frontend/app/auth/oauth/authorize/page.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Loader2, ShieldCheck } from 'lucide-react'; +import { BrandLogo } from '@/components/id/brand-logo'; +import { useAuth } from '@/components/id/auth-provider'; +import { Button } from '@/components/ui/button'; + +function OAuthAuthorizeContent() { + const searchParams = useSearchParams(); + const router = useRouter(); + const { user, token, isPinLocked, isLoading } = useAuth(); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const oauthQuery = useMemo(() => { + const params = new URLSearchParams(); + searchParams.forEach((value, key) => params.set(key, value)); + return params; + }, [searchParams]); + + const clientId = searchParams.get('client_id') ?? searchParams.get('clientId'); + const redirectUri = searchParams.get('redirect_uri') ?? searchParams.get('redirectUri'); + const scope = searchParams.get('scope') ?? 'openid profile'; + + useEffect(() => { + if (isLoading) return; + if (!clientId || !redirectUri) return; + if (user && token && !isPinLocked) return; + const returnUrl = `/auth/oauth/authorize?${oauthQuery.toString()}`; + router.replace(`/auth/login?redirect=${encodeURIComponent(returnUrl)}`); + }, [clientId, isLoading, isPinLocked, oauthQuery, redirectUri, router, token, user]); + + const approve = useCallback(async () => { + if (!token || !user) return; + setSubmitting(true); + setError(null); + try { + const apiBase = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000').replace(/\/$/, ''); + const url = new URL(`${apiBase}/oauth/authorize`); + oauthQuery.forEach((value, key) => url.searchParams.set(key, value)); + url.searchParams.set('userId', user.id); + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json' + } + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string | string[] } | null; + const message = Array.isArray(payload?.message) ? payload.message.join(', ') : payload?.message; + throw new Error(message || 'Не удалось подтвердить доступ'); + } + + const data = (await response.json()) as { redirectUrl?: string }; + if (!data.redirectUrl) { + throw new Error('Сервер не вернул redirect URL'); + } + window.location.href = data.redirectUrl; + } catch (err) { + setError(err instanceof Error ? err.message : 'Ошибка OAuth авторизации'); + } finally { + setSubmitting(false); + } + }, [oauthQuery, token, user]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!clientId || !redirectUri) { + return ( +
+

Некорректный OAuth запрос

+

Отсутствуют обязательные параметры client_id и redirect_uri.

+ + На главную + +
+ ); + } + + if (!user || !token || isPinLocked) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +
+
+
+ +
+

Разрешить доступ?

+

+ Приложение {clientId} запрашивает доступ к вашему аккаунту Lendry ID. +

+
+

+ Пользователь: {user.displayName} +

+

+ Scopes: {scope} +

+

+ Redirect URI: {redirectUri} +

+
+ {error ?

{error}

: null} +
+ + +
+
+
+ ); +} + +export default function OAuthAuthorizePage() { + return ( + + + + } + > + + + ); +} diff --git a/apps/frontend/app/family/[groupId]/page.tsx b/apps/frontend/app/family/[groupId]/page.tsx index 30f1dd7..0c6d9b5 100644 --- a/apps/frontend/app/family/[groupId]/page.tsx +++ b/apps/frontend/app/family/[groupId]/page.tsx @@ -5,17 +5,22 @@ import { FamilyGroupView } from '@/components/family/family-group-view'; import { useFamilyOverlay } from '@/components/family/family-overlay-provider'; import { IdShell } from '@/components/id/shell'; -export default function FamilyGroupPage({ params }: { params: Promise<{ groupId: string }> }) { - const { groupId } = use(params); +function FamilyGroupPageContent({ groupId }: { groupId: string }) { const { setSelectedGroupId } = useFamilyOverlay(); useEffect(() => { setSelectedGroupId(groupId); }, [groupId, setSelectedGroupId]); + return ; +} + +export default function FamilyGroupPage({ params }: { params: Promise<{ groupId: string }> }) { + const { groupId } = use(params); + return ( - + ); } diff --git a/apps/frontend/app/globals.css b/apps/frontend/app/globals.css index 68ab2d8..743cefd 100644 --- a/apps/frontend/app/globals.css +++ b/apps/frontend/app/globals.css @@ -46,6 +46,20 @@ input { background: var(--muted); } +@keyframes chat-message-blink { + 0%, 100% { + background-color: transparent; + } + 25%, 75% { + background-color: rgb(51 144 236 / 18%); + } +} + +.blink-highlight { + animation: chat-message-blink 0.55s ease-in-out 2; + border-radius: 18px; +} + .id-shadow { box-shadow: 0 24px 70px rgb(22 26 43 / 12%); } diff --git a/apps/frontend/app/mini-apps/bot-create/content.tsx b/apps/frontend/app/mini-apps/bot-create/content.tsx new file mode 100644 index 0000000..164a934 --- /dev/null +++ b/apps/frontend/app/mini-apps/bot-create/content.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { Check, Copy, Loader2, Bot } from 'lucide-react'; +import { useAuth } from '@/components/id/auth-provider'; +import { useToast } from '@/components/id/toast-provider'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { createManagedBot, getApiErrorMessage } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +type Step = 'name' | 'username' | 'done'; + +export function BotCreateMiniAppContent() { + const { token, user, isPinLocked } = useAuth(); + const { showToast } = useToast(); + + const [step, setStep] = useState('name'); + const [name, setName] = useState(''); + const [username, setUsername] = useState(''); + const [creating, setCreating] = useState(false); + const [createdBot, setCreatedBot] = useState<{ username: string; token: string; botId: string } | null>(null); + + const canUse = Boolean(token && user && !isPinLocked); + const usernamePreview = useMemo(() => `${username.replace(/_bot$/i, '').trim()}_bot`, [username]); + + async function handleCreate() { + if (!canUse) return; + const trimmedName = name.trim(); + const trimmedUsername = username.replace(/_bot$/i, '').trim(); + if (!trimmedName) { + showToast('Укажите название бота'); + setStep('name'); + return; + } + if (trimmedUsername.length < 5) { + showToast('Username должен содержать минимум 5 символов'); + return; + } + + setCreating(true); + try { + const response = await createManagedBot({ name: trimmedName, username: trimmedUsername }, token); + setCreatedBot({ + username: response.bot?.username ?? usernamePreview, + token: response.token ?? '', + botId: response.bot?.id ?? '' + }); + setStep('done'); + showToast('Бот успешно создан'); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось создать бота') ?? 'Ошибка'); + } finally { + setCreating(false); + } + } + + async function copyToken() { + if (!createdBot?.token) return; + await navigator.clipboard.writeText(createdBot.token); + showToast('Токен скопирован'); + } + + if (!canUse) { + return ( +
+ Войдите в Lendry ID, чтобы создать бота +
+ ); + } + + return ( +
+
+
+
+ +
+
+

Создание бота

+

Как в BotFather Telegram

+
+
+ +
+ {(['name', 'username', 'done'] as Step[]).map((item, index) => ( +
+ + {step === 'done' && item !== 'done' ? : index + 1} + + {index < 2 ? : null} +
+ ))} +
+ + {step === 'name' ? ( +
+

+ Alright, a new bot. How are we going to call it? Please choose a name for your bot. +

+

Хорошо, новый бот. Как мы его назовём? Выберите название.

+ setName(event.target.value)} + placeholder="Например: Сервис уведомлений" + className="rounded-xl border-[#2a3544] bg-[#17212b] text-white placeholder:text-[#667085]" + autoFocus + /> + +
+ ) : null} + + {step === 'username' ? ( +
+

+ Good. Now let's choose a username for your bot. It must end in `bot`. +

+

Username должен заканчиваться на `_bot` (суффикс добавится автоматически).

+
+ @ + setUsername(event.target.value.replace(/[^a-zA-Z0-9_]/g, ''))} + placeholder="notify_service" + className="rounded-xl border-[#2a3544] bg-[#17212b] text-white placeholder:text-[#667085]" + autoFocus + /> +
+

Будет: @{usernamePreview || 'your_bot'}

+
+ + +
+
+ ) : null} + + {step === 'done' && createdBot ? ( +
+
+

Done! Congratulations on your new bot.

+

@{createdBot.username}

+

Сохраните токен — он больше не будет показан:

+
+ + +
+
+ +
+ ) : null} +
+
+ ); +} diff --git a/apps/frontend/app/mini-apps/bot-create/page.tsx b/apps/frontend/app/mini-apps/bot-create/page.tsx new file mode 100644 index 0000000..a0b20ed --- /dev/null +++ b/apps/frontend/app/mini-apps/bot-create/page.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { Suspense } from 'react'; +import { BotCreateMiniAppContent } from './content'; + +export default function BotCreateMiniAppPage() { + return ( + Загрузка... + } + > + + + ); +} diff --git a/apps/frontend/app/mini-apps/bot-manage/content.tsx b/apps/frontend/app/mini-apps/bot-manage/content.tsx new file mode 100644 index 0000000..d09626f --- /dev/null +++ b/apps/frontend/app/mini-apps/bot-manage/content.tsx @@ -0,0 +1,371 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { Copy, ImageIcon, LayoutGrid, Loader2, RefreshCw, TextQuote, UserCircle2 } from 'lucide-react'; +import { useAuth } from '@/components/id/auth-provider'; +import { useToast } from '@/components/id/toast-provider'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + fetchBot, + getApiErrorMessage, + revokeManagedBotToken, + updateManagedBot, + updateManagedBotProfile +} from '@/lib/api'; +import { parseComposerMenuButtonJson } from '@/lib/bot-menu-button'; + +function FieldTextarea({ + label, + hint, + value, + onChange, + maxLength, + rows = 3 +}: { + label: string; + hint?: string; + value: string; + onChange: (value: string) => void; + maxLength?: number; + rows?: number; +}) { + return ( +
+ + {hint ?

{hint}

: null} +