global update and global fix
This commit is contained in:
@@ -20,6 +20,9 @@ import { FamilyController } from './controllers/family.controller';
|
|||||||
import { ChatController } from './controllers/chat.controller';
|
import { ChatController } from './controllers/chat.controller';
|
||||||
import { NotificationsController } from './controllers/notifications.controller';
|
import { NotificationsController } from './controllers/notifications.controller';
|
||||||
import { MediaController } from './controllers/media.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 { CoreGrpcService } from './core-grpc.service';
|
||||||
import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.guard';
|
import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.guard';
|
||||||
|
|
||||||
@@ -35,7 +38,7 @@ import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.gua
|
|||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
transport: Transport.GRPC,
|
transport: Transport.GRPC,
|
||||||
options: {
|
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: [
|
protoPath: [
|
||||||
join(__dirname, '../../../shared/proto/auth.proto'),
|
join(__dirname, '../../../shared/proto/auth.proto'),
|
||||||
join(__dirname, '../../../shared/proto/admin.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/identity.proto'),
|
||||||
join(__dirname, '../../../shared/proto/media.proto'),
|
join(__dirname, '../../../shared/proto/media.proto'),
|
||||||
join(__dirname, '../../../shared/proto/notifications.proto'),
|
join(__dirname, '../../../shared/proto/notifications.proto'),
|
||||||
join(__dirname, '../../../shared/proto/chat.proto')
|
join(__dirname, '../../../shared/proto/chat.proto'),
|
||||||
|
join(__dirname, '../../../shared/proto/bot.proto')
|
||||||
],
|
],
|
||||||
url: config.get<string>('SSO_CORE_GRPC_URL', 'localhost:50051')
|
url: config.get<string>('SSO_CORE_GRPC_URL', 'localhost:50051')
|
||||||
}
|
}
|
||||||
@@ -55,7 +59,7 @@ import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.gua
|
|||||||
}
|
}
|
||||||
])
|
])
|
||||||
],
|
],
|
||||||
controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, WellKnownController, OAuthAuthorizeDiscoveryController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController],
|
controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, WellKnownController, OAuthAuthorizeDiscoveryController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController, BotController, AdminBotController, TelegramBotApiController],
|
||||||
providers: [CoreGrpcService, AdminGuard, RbacManageGuard, SuperAdminGuard]
|
providers: [CoreGrpcService, AdminGuard, RbacManageGuard, SuperAdminGuard]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
68
apps/api-gateway/src/controllers/admin-bot.controller.ts
Normal file
68
apps/api-gateway/src/controllers/admin-bot.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Body, Controller, ForbiddenException, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||||
|
import { ListAdminBotsQueryDto, SetBotActiveDto } from '../dto/bot.dto';
|
||||||
|
import { AdminGuard, AdminRequestUser } from '../guards/admin.guard';
|
||||||
|
|
||||||
|
function assertManageAllBots(admin: AdminRequestUser) {
|
||||||
|
if (admin.isSuperAdmin || admin.permissions.includes('bots.manage.all')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('Недостаточно прав для управления всеми ботами');
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Администрирование ботов')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@Controller('admin/bots')
|
||||||
|
export class AdminBotController {
|
||||||
|
constructor(private readonly core: CoreGrpcService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Список всех ботов', description: 'Административный список Telegram-ботов с поиском и пагинацией.' })
|
||||||
|
listAllBots(@Query() query: ListAdminBotsQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
assertManageAllBots(admin);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.bot.ListAllBots({
|
||||||
|
requesterId: admin.id,
|
||||||
|
isSuperAdmin: admin.isSuperAdmin,
|
||||||
|
search: query.search,
|
||||||
|
page: query.page,
|
||||||
|
limit: query.limit
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('metrics')
|
||||||
|
@ApiOperation({ summary: 'Метрики ботов', description: 'Сводная статистика по ботам системы.' })
|
||||||
|
getMetrics(@CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
assertManageAllBots(admin);
|
||||||
|
return firstValueFrom(this.core.bot.GetBotMetrics({ requesterId: admin.id, isSuperAdmin: admin.isSuperAdmin }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':botId')
|
||||||
|
@ApiOperation({ summary: 'Получить бота (админ)', description: 'Возвращает любого бота по ID.' })
|
||||||
|
getBot(@Param('botId') botId: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
assertManageAllBots(admin);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.bot.GetBot({ requesterId: admin.id, botId, isSuperAdmin: admin.isSuperAdmin })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':botId/active')
|
||||||
|
@ApiOperation({ summary: 'Заблокировать или разблокировать бота', description: 'Анти-abuse: отключает Bot API токен.' })
|
||||||
|
@ApiBody({ type: SetBotActiveDto })
|
||||||
|
setActive(@Param('botId') botId: string, @Body() dto: SetBotActiveDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
assertManageAllBots(admin);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.bot.SetBotActive({
|
||||||
|
requesterId: admin.id,
|
||||||
|
botId,
|
||||||
|
isActive: dto.isActive,
|
||||||
|
isSuperAdmin: admin.isSuperAdmin
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
159
apps/api-gateway/src/controllers/bot.controller.ts
Normal file
159
apps/api-gateway/src/controllers/bot.controller.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
import { CreateBotDto, SetBotWebAppDto, SubmitBotCallbackDto, SubmitBotMessageDto, UpdateBotDto, UpdateBotProfileDto, ValidateWebAppInitDataDto } from '../dto/bot.dto';
|
||||||
|
import { getAuthorizedUserId } from '../document-access';
|
||||||
|
|
||||||
|
@ApiTags('BotFather')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('bots')
|
||||||
|
export class BotController {
|
||||||
|
constructor(
|
||||||
|
private readonly core: CoreGrpcService,
|
||||||
|
private readonly jwt: JwtService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async auth(authorization?: string) {
|
||||||
|
return getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Список моих ботов', description: 'Возвращает Telegram-ботов текущего пользователя.' })
|
||||||
|
listMyBots(@Headers('authorization') authorization?: string) {
|
||||||
|
return this.auth(authorization).then((userId) => firstValueFrom(this.core.bot.ListMyBots({ ownerId: userId })));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Создать бота', description: 'Регистрирует нового бота и возвращает токен Bot API.' })
|
||||||
|
@ApiBody({ type: CreateBotDto })
|
||||||
|
createBot(@Headers('authorization') authorization: string | undefined, @Body() dto: CreateBotDto) {
|
||||||
|
return this.auth(authorization).then((ownerId) =>
|
||||||
|
firstValueFrom(this.core.bot.CreateBot({ ownerId, name: dto.name, username: dto.username }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('by-username/:botRef/messages')
|
||||||
|
@ApiOperation({ summary: 'История чата с ботом', description: 'Возвращает сообщения пользователя с ботом в хронологическом порядке.' })
|
||||||
|
listBotMessages(@Headers('authorization') authorization: string | undefined, @Param('botRef') botRef: string) {
|
||||||
|
return this.auth(authorization).then((userId) =>
|
||||||
|
firstValueFrom(this.core.bot.ListBotChatMessages({ userId, botRef }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('by-username/:botRef/messages')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Написать боту',
|
||||||
|
description: 'Публикует inbound-событие в RabbitMQ для доставки боту через webhook или getUpdates.'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: SubmitBotMessageDto })
|
||||||
|
submitMessage(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('botRef') botRef: string,
|
||||||
|
@Body() dto: SubmitBotMessageDto
|
||||||
|
) {
|
||||||
|
return this.auth(authorization).then((senderUserId) =>
|
||||||
|
firstValueFrom(this.core.bot.SubmitBotInboundMessage({ senderUserId, botRef, text: dto.text }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('by-username/:botRef/callback')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Нажатие inline-кнопки',
|
||||||
|
description: 'Публикует callback_query Update для webhook или getUpdates.'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: SubmitBotCallbackDto })
|
||||||
|
submitCallback(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('botRef') botRef: string,
|
||||||
|
@Body() dto: SubmitBotCallbackDto
|
||||||
|
) {
|
||||||
|
return this.auth(authorization).then((senderUserId) =>
|
||||||
|
firstValueFrom(
|
||||||
|
this.core.bot.SubmitBotCallbackQuery({
|
||||||
|
senderUserId,
|
||||||
|
botRef,
|
||||||
|
messageId: dto.messageId,
|
||||||
|
callbackData: dto.callbackData
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('web-app/validate')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Проверить initData Mini App',
|
||||||
|
description: 'Валидирует Telegram Web App initData по HMAC-SHA256, как в официальном Bot API.'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: ValidateWebAppInitDataDto })
|
||||||
|
validateWebApp(@Body() dto: ValidateWebAppInitDataDto) {
|
||||||
|
return firstValueFrom(this.core.bot.ValidateWebAppInitData(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':botId')
|
||||||
|
@ApiOperation({ summary: 'Получить бота', description: 'Возвращает настройки бота, принадлежащего пользователю.' })
|
||||||
|
getBot(@Headers('authorization') authorization: string | undefined, @Param('botId') botId: string) {
|
||||||
|
return this.auth(authorization).then((requesterId) =>
|
||||||
|
firstValueFrom(this.core.bot.GetBot({ requesterId, botId, isSuperAdmin: false }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':botId')
|
||||||
|
@ApiOperation({ summary: 'Обновить бота', description: 'Изменяет имя или username бота.' })
|
||||||
|
@ApiBody({ type: UpdateBotDto })
|
||||||
|
updateBot(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('botId') botId: string,
|
||||||
|
@Body() dto: UpdateBotDto
|
||||||
|
) {
|
||||||
|
return this.auth(authorization).then((requesterId) =>
|
||||||
|
firstValueFrom(this.core.bot.UpdateBot({ requesterId, botId, ...dto, isSuperAdmin: false }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':botId')
|
||||||
|
@ApiOperation({ summary: 'Удалить бота', description: 'Удаляет бота и все связанные чаты/сообщения.' })
|
||||||
|
deleteBot(@Headers('authorization') authorization: string | undefined, @Param('botId') botId: string) {
|
||||||
|
return this.auth(authorization).then((requesterId) =>
|
||||||
|
firstValueFrom(this.core.bot.DeleteBot({ requesterId, botId, isSuperAdmin: false }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':botId/revoke-token')
|
||||||
|
@ApiOperation({ summary: 'Перевыпустить токен', description: 'Инвалидирует старый токен и возвращает новый.' })
|
||||||
|
revokeToken(@Headers('authorization') authorization: string | undefined, @Param('botId') botId: string) {
|
||||||
|
return this.auth(authorization).then((requesterId) =>
|
||||||
|
firstValueFrom(this.core.bot.RevokeBotToken({ requesterId, botId, isSuperAdmin: false }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':botId/profile')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Профиль бота',
|
||||||
|
description: 'Обновляет description, aboutText, botPicUrl и глобальную кнопку меню (Web App).'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: UpdateBotProfileDto })
|
||||||
|
updateBotProfile(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('botId') botId: string,
|
||||||
|
@Body() dto: UpdateBotProfileDto
|
||||||
|
) {
|
||||||
|
return this.auth(authorization).then((requesterId) =>
|
||||||
|
firstValueFrom(this.core.bot.UpdateBotProfile({ requesterId, botId, ...dto, isSuperAdmin: false }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':botId/web-app')
|
||||||
|
@ApiOperation({ summary: 'Настроить Mini App', description: 'Привязывает URL Web App к боту.' })
|
||||||
|
@ApiBody({ type: SetBotWebAppDto })
|
||||||
|
setWebApp(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('botId') botId: string,
|
||||||
|
@Body() dto: SetBotWebAppDto
|
||||||
|
) {
|
||||||
|
return this.auth(authorization).then((requesterId) =>
|
||||||
|
firstValueFrom(this.core.bot.SetBotWebApp({ requesterId, botId, webAppUrl: dto.webAppUrl, isSuperAdmin: false }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,13 @@ import { getAuthorizedUserId } from '../document-access';
|
|||||||
import {
|
import {
|
||||||
AddChatRoomMemberDto,
|
AddChatRoomMemberDto,
|
||||||
CreateChatRoomDto,
|
CreateChatRoomDto,
|
||||||
|
CreateE2EChatRoomDto,
|
||||||
EditChatMessageDto,
|
EditChatMessageDto,
|
||||||
|
ForwardMessagesDto,
|
||||||
MarkRoomReadDto,
|
MarkRoomReadDto,
|
||||||
SendChatMessageDto,
|
SendChatMessageDto,
|
||||||
SetRoomNotificationsMutedDto,
|
SetRoomNotificationsMutedDto,
|
||||||
|
ToggleMessageReactionDto,
|
||||||
UpdateChatRoomDto,
|
UpdateChatRoomDto,
|
||||||
VotePollDto
|
VotePollDto
|
||||||
} from '../dto/chat.dto';
|
} 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')
|
@Patch('rooms/:roomId')
|
||||||
@ApiOperation({ summary: 'Настройки чата' })
|
@ApiOperation({ summary: 'Настройки чата' })
|
||||||
async updateRoom(
|
async updateRoom(
|
||||||
@@ -138,7 +152,8 @@ export class ChatController {
|
|||||||
storageKey: dto.storageKey,
|
storageKey: dto.storageKey,
|
||||||
mimeType: dto.mimeType,
|
mimeType: dto.mimeType,
|
||||||
metadataJson: dto.metadataJson,
|
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 }));
|
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')
|
@Post('rooms/:roomId/read')
|
||||||
@ApiOperation({ summary: 'Отметить сообщения чата прочитанными' })
|
@ApiOperation({ summary: 'Отметить сообщения чата прочитанными' })
|
||||||
async markRoomRead(
|
async markRoomRead(
|
||||||
@@ -195,4 +239,21 @@ export class ChatController {
|
|||||||
this.core.chat.MarkRoomRead({ userId, roomId, lastMessageId: dto.lastMessageId })
|
this.core.chat.MarkRoomRead({ userId, roomId, lastMessageId: dto.lastMessageId })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('rooms/:roomId/typing')
|
||||||
|
@ApiOperation({ summary: 'Сообщить о наборе текста в чате' })
|
||||||
|
async reportTyping(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('roomId') roomId: string
|
||||||
|
) {
|
||||||
|
const userId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(this.core.chat.ReportTyping({ userId, roomId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('rooms/:roomId')
|
||||||
|
@ApiOperation({ summary: 'Удалить чат' })
|
||||||
|
async deleteRoom(@Headers('authorization') authorization: string | undefined, @Param('roomId') roomId: string) {
|
||||||
|
const userId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(this.core.chat.DeleteRoom({ userId, roomId }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs';
|
|||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { getAuthorizedUserId } from '../document-access';
|
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('Семья')
|
@ApiTags('Семья')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -151,5 +151,24 @@ export class FamilyController {
|
|||||||
const requesterId = await this.auth(authorization);
|
const requesterId = await this.auth(authorization);
|
||||||
return firstValueFrom(this.core.family.GetFamilyPresence({ requesterId, groupId }));
|
return firstValueFrom(this.core.family.GetFamilyPresence({ requesterId, groupId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('groups/:groupId/botfather')
|
||||||
|
@ApiOperation({ summary: 'Добавить BotFather в семью', description: 'Добавляет системного бота BotFather для создания Telegram-ботов через чат.' })
|
||||||
|
async addBotFather(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) {
|
||||||
|
const requesterId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(this.core.family.AddBotFatherToFamily({ requesterId, groupId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('groups/:groupId/bots')
|
||||||
|
@ApiOperation({ summary: 'Добавить бота в семью', description: 'Добавляет Telegram-бота (своего или другого пользователя) в семейную группу.' })
|
||||||
|
@ApiBody({ type: AddBotToFamilyDto })
|
||||||
|
async addBot(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('groupId') groupId: string,
|
||||||
|
@Body() dto: AddBotToFamilyDto
|
||||||
|
) {
|
||||||
|
const requesterId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(this.core.family.AddBotToFamily({ requesterId, groupId, botId: dto.botId }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,129 @@
|
|||||||
import { Body, Controller, Get, Headers, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Headers, Post, Query, Res, UnauthorizedException, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
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 { 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')
|
@ApiTags('OAuth 2.0')
|
||||||
@Controller('oauth')
|
@Controller('oauth')
|
||||||
export class OAuthController {
|
export class OAuthController {
|
||||||
constructor(private readonly core: CoreGrpcService) {}
|
constructor(
|
||||||
|
private readonly core: CoreGrpcService,
|
||||||
|
private readonly jwt: JwtService
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('authorize')
|
@Get('authorize')
|
||||||
@ApiOperation({ summary: 'OAuth авторизация', description: 'Создает authorization_code и возвращает redirectUrl для OAuth клиента. Consent считается подтвержденным для переданного userId.' })
|
@UsePipes(oauthValidationPipe)
|
||||||
@ApiQuery({ name: 'userId', description: 'ID пользователя' })
|
@ApiOperation({
|
||||||
@ApiQuery({ name: 'clientId', description: 'OAuth client_id' })
|
summary: 'OAuth / OIDC авторизация',
|
||||||
@ApiQuery({ name: 'redirectUri', description: 'redirect_uri' })
|
description:
|
||||||
@ApiQuery({ name: 'scope', description: 'Scopes через пробел' })
|
'Поддерживает стандартные query-параметры RFC 6749 / OIDC (client_id, redirect_uri, response_type, code_challenge) и legacy camelCase. ' +
|
||||||
@ApiResponse({ status: 200, description: 'Authorization code создан' })
|
'Без userId и без Bearer-токена перенаправляет на страницу подтверждения доступа.'
|
||||||
authorize(@Query() query: OAuthAuthorizeQueryDto) {
|
})
|
||||||
return this.core.oauth.Authorize(query);
|
@ApiQuery({ name: 'client_id', required: false, description: 'OAuth client_id (RFC 6749)' })
|
||||||
|
@ApiQuery({ name: 'clientId', required: false, description: 'OAuth client_id (legacy)' })
|
||||||
|
@ApiQuery({ name: 'redirect_uri', required: false })
|
||||||
|
@ApiQuery({ name: 'redirectUri', required: false })
|
||||||
|
@ApiQuery({ name: 'scope', required: false })
|
||||||
|
@ApiQuery({ name: 'state', required: false })
|
||||||
|
@ApiQuery({ name: 'response_type', required: false, example: 'code' })
|
||||||
|
@ApiQuery({ name: 'code_challenge', required: false })
|
||||||
|
@ApiQuery({ name: 'code_challenge_method', required: false })
|
||||||
|
@ApiQuery({ name: 'userId', required: false, description: 'Legacy: ID пользователя после входа' })
|
||||||
|
@ApiResponse({ status: 302, description: 'Redirect на redirect_uri с authorization code' })
|
||||||
|
async authorize(
|
||||||
|
@Query() query: OAuthAuthorizeIncomingDto,
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Headers('accept') acceptHeader: string | undefined,
|
||||||
|
@Res({ passthrough: true }) res: HttpResponse
|
||||||
|
) {
|
||||||
|
const normalized = normalizeAuthorizeQuery(query as Record<string, unknown>);
|
||||||
|
let userId = normalized.userId;
|
||||||
|
|
||||||
|
if (!userId && authorization) {
|
||||||
|
try {
|
||||||
|
const payload = await verifyAccessToken(this.jwt, authorization);
|
||||||
|
userId = payload.sub;
|
||||||
|
} catch {
|
||||||
|
// handled below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
const frontendUrl = await resolveFrontendUrl(this.core);
|
||||||
|
const consentUrl = appendQueryParams(`${frontendUrl}/auth/oauth/authorize`, query as Record<string, unknown>);
|
||||||
|
res.redirect(302, consentUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await firstValueFrom(
|
||||||
|
this.core.oauth.Authorize({
|
||||||
|
userId,
|
||||||
|
clientId: normalized.clientId,
|
||||||
|
redirectUri: normalized.redirectUri,
|
||||||
|
scope: normalized.scope,
|
||||||
|
state: normalized.state
|
||||||
|
})
|
||||||
|
)) as { redirectUrl?: string; code?: string; state?: string };
|
||||||
|
|
||||||
|
const accept = acceptHeader ?? '';
|
||||||
|
const wantsJson = authorization?.startsWith('Bearer') || accept.includes('application/json');
|
||||||
|
if (wantsJson) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.redirectUrl) {
|
||||||
|
throw new UnauthorizedException('Не удалось создать authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect(302, result.redirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('token')
|
@Post('token')
|
||||||
@ApiOperation({ summary: 'Выдать OAuth токены', description: 'Поддерживает grant_type=authorization_code и grant_type=refresh_token.' })
|
@UsePipes(oauthValidationPipe)
|
||||||
@ApiBody({ type: OAuthTokenDto })
|
@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 токены выданы' })
|
@ApiResponse({ status: 201, description: 'OAuth токены выданы' })
|
||||||
token(@Body() dto: OAuthTokenDto) {
|
async token(@Body() body: OAuthTokenIncomingDto, @Headers('authorization') authorization?: string) {
|
||||||
return this.core.oauth.Token(dto);
|
const normalized = mergeTokenCredentials(normalizeTokenBody(body as Record<string, unknown>), authorization);
|
||||||
|
const result = (await firstValueFrom(
|
||||||
|
this.core.oauth.Token({
|
||||||
|
grantType: normalized.grantType,
|
||||||
|
code: normalized.code,
|
||||||
|
refreshToken: normalized.refreshToken,
|
||||||
|
clientId: normalized.clientId,
|
||||||
|
clientSecret: normalized.clientSecret,
|
||||||
|
redirectUri: normalized.redirectUri
|
||||||
|
})
|
||||||
|
)) as {
|
||||||
|
accessToken?: string;
|
||||||
|
tokenType?: string;
|
||||||
|
expiresIn?: number;
|
||||||
|
refreshToken?: string;
|
||||||
|
idToken?: string;
|
||||||
|
};
|
||||||
|
return mapTokenResponseToStandard(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('userinfo')
|
@Get('userinfo')
|
||||||
|
|||||||
@@ -139,5 +139,22 @@ export class ProfileController {
|
|||||||
await this.assertSelfAccess(authorization, userId);
|
await this.assertSelfAccess(authorization, userId);
|
||||||
return this.core.profile.GetAccountDeletionStatus({ userId });
|
return this.core.profile.GetAccountDeletionStatus({ userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('e2e-public-key')
|
||||||
|
@ApiOperation({ summary: 'Получить публичный E2E-ключ пользователя' })
|
||||||
|
async getE2EPublicKey(@Param('userId') userId: string) {
|
||||||
|
return this.core.profile.GetE2EPublicKey({ userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('e2e-public-key')
|
||||||
|
@ApiOperation({ summary: 'Сохранить свой публичный E2E-ключ' })
|
||||||
|
async setE2EPublicKey(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Body() dto: { publicKey: string }
|
||||||
|
) {
|
||||||
|
await this.assertSelfAccess(authorization, userId);
|
||||||
|
return this.core.profile.SetE2EPublicKey({ userId, publicKey: dto.publicKey });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { All, Body, Controller, HttpCode, Param, Req, Res } from '@nestjs/common';
|
||||||
|
import { ApiExcludeController } from '@nestjs/swagger';
|
||||||
|
import { firstValueFrom, timeout } from 'rxjs';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
|
||||||
|
type HttpRequest = {
|
||||||
|
method: string;
|
||||||
|
query: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HttpResponse = {
|
||||||
|
status(code: number): HttpResponse;
|
||||||
|
setHeader(name: string, value: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiExcludeController()
|
||||||
|
@Controller()
|
||||||
|
export class TelegramBotApiController {
|
||||||
|
constructor(private readonly core: CoreGrpcService) {}
|
||||||
|
|
||||||
|
@All(':botPath/:method')
|
||||||
|
@HttpCode(200)
|
||||||
|
async handleTelegramMethod(
|
||||||
|
@Req() request: HttpRequest,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
@Param('botPath') botPath: string,
|
||||||
|
@Param('method') method: string,
|
||||||
|
@Body() body: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
if (!botPath.startsWith('bot')) {
|
||||||
|
response.status(404);
|
||||||
|
return { ok: false, error_code: 404, description: 'Not Found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = decodeURIComponent(botPath.slice(3));
|
||||||
|
if (!token) {
|
||||||
|
response.status(401);
|
||||||
|
return { ok: false, error_code: 401, description: 'Unauthorized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = request.method === 'GET' ? { ...request.query } : body ?? {};
|
||||||
|
const grpcCall = this.core.bot.ExecuteBotMethod({
|
||||||
|
token,
|
||||||
|
method,
|
||||||
|
payloadJson: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const waitSeconds = method === 'getUpdates' ? Number(payload.timeout ?? 0) : 0;
|
||||||
|
const grpcTimeoutMs = method === 'getUpdates' ? Math.min(Math.max(waitSeconds, 0), 50) * 1000 + 10_000 : 30_000;
|
||||||
|
|
||||||
|
const result = (await firstValueFrom(
|
||||||
|
grpcCall.pipe(timeout(grpcTimeoutMs)) as typeof grpcCall
|
||||||
|
)) as { responseJson: string; httpStatus: number };
|
||||||
|
|
||||||
|
response.status(result.httpStatus ?? 200);
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
return JSON.parse(result.responseJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export class CoreGrpcService implements OnModuleInit {
|
|||||||
chat!: Record<string, GrpcMethod>;
|
chat!: Record<string, GrpcMethod>;
|
||||||
family!: Record<string, GrpcMethod>;
|
family!: Record<string, GrpcMethod>;
|
||||||
media!: Record<string, GrpcMethod>;
|
media!: Record<string, GrpcMethod>;
|
||||||
|
bot!: Record<string, GrpcMethod>;
|
||||||
|
|
||||||
constructor(@Inject('SSO_CORE') private readonly client: ClientGrpc) {}
|
constructor(@Inject('SSO_CORE') private readonly client: ClientGrpc) {}
|
||||||
|
|
||||||
@@ -40,5 +41,6 @@ export class CoreGrpcService implements OnModuleInit {
|
|||||||
this.notifications = this.client.getService<Record<string, GrpcMethod>>('NotificationsService');
|
this.notifications = this.client.getService<Record<string, GrpcMethod>>('NotificationsService');
|
||||||
this.chat = this.client.getService<Record<string, GrpcMethod>>('ChatService');
|
this.chat = this.client.getService<Record<string, GrpcMethod>>('ChatService');
|
||||||
this.media = this.client.getService<Record<string, GrpcMethod>>('MediaService');
|
this.media = this.client.getService<Record<string, GrpcMethod>>('MediaService');
|
||||||
|
this.bot = this.client.getService<Record<string, GrpcMethod>>('BotService');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
101
apps/api-gateway/src/dto/bot.dto.ts
Normal file
101
apps/api-gateway/src/dto/bot.dto.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Max, Min, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateBotDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2, { message: 'Название бота должно содержать минимум 2 символа' })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5, { message: 'Username бота должен содержать минимум 5 символов' })
|
||||||
|
username!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBotDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2, { message: 'Название бота должно содержать минимум 2 символа' })
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5, { message: 'Username бота должен содержать минимум 5 символов' })
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetBotWebAppDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl({}, { message: 'Укажите корректный URL Mini App' })
|
||||||
|
webAppUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBotProfileDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
aboutText?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
botPicUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
menuButtonJson?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
menuButtonUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
menuButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetBotActiveDto {
|
||||||
|
@IsBoolean()
|
||||||
|
isActive!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListAdminBotsQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidateWebAppInitDataDto {
|
||||||
|
@IsString()
|
||||||
|
initData!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
botToken!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubmitBotMessageDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1, { message: 'Текст сообщения не может быть пустым' })
|
||||||
|
text!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubmitBotCallbackDto {
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
messageId!: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1, { message: 'callbackData не может быть пустым' })
|
||||||
|
callbackData!: string;
|
||||||
|
}
|
||||||
@@ -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 {
|
export class CreateChatRoomDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -11,6 +11,11 @@ export class CreateChatRoomDto {
|
|||||||
memberUserIds?: string[];
|
memberUserIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CreateE2EChatRoomDto {
|
||||||
|
@IsString()
|
||||||
|
peerUserId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateChatRoomDto {
|
export class UpdateChatRoomDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -54,6 +59,10 @@ export class SendChatMessageDto {
|
|||||||
allowsMultiple?: boolean;
|
allowsMultiple?: boolean;
|
||||||
isAnonymous?: boolean;
|
isAnonymous?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isEncrypted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VotePollDto {
|
export class VotePollDto {
|
||||||
@@ -83,3 +92,16 @@ export class MarkRoomReadDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
lastMessageId?: string;
|
lastMessageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ToggleMessageReactionDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
emoji!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForwardMessagesDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsString({ each: true })
|
||||||
|
messageIds!: string[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,3 +153,10 @@ export class RespondFamilyInviteDto {
|
|||||||
@IsBoolean({ message: 'Укажите accept: true или false' })
|
@IsBoolean({ message: 'Укажите accept: true или false' })
|
||||||
accept!: boolean;
|
accept!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AddBotToFamilyDto {
|
||||||
|
@ApiProperty({ description: 'ID бота для добавления в семью' })
|
||||||
|
@IsString({ message: 'ID бота должен быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'Укажите ID бота' })
|
||||||
|
botId!: string;
|
||||||
|
}
|
||||||
|
|||||||
133
apps/api-gateway/src/dto/oauth.dto.ts
Normal file
133
apps/api-gateway/src/dto/oauth.dto.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class OAuthAuthorizeIncomingDto {
|
||||||
|
@ApiPropertyOptional({ description: 'ID пользователя (legacy camelCase)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'ID пользователя (OIDC legacy alias)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
user_id?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'OAuth client_id (legacy camelCase)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
clientId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'OAuth client_id (RFC 6749)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
client_id?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'redirect_uri (legacy camelCase)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
redirectUri?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'redirect_uri (RFC 6749)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
redirect_uri?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Scopes через пробел', example: 'openid profile email' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
scope?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'OAuth state' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
state?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'OAuth response_type', example: 'code' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
response_type?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Legacy camelCase alias response_type' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
responseType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'PKCE code_challenge' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
code_challenge?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Legacy camelCase alias code_challenge' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
codeChallenge?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'PKCE code_challenge_method', example: 'S256' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
code_challenge_method?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Legacy camelCase alias code_challenge_method' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
codeChallengeMethod?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'OpenID Connect nonce' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nonce?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OAuthTokenIncomingDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
grantType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
grant_type?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
refreshToken?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
refresh_token?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
clientId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
client_id?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
clientSecret?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
client_secret?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
redirectUri?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
redirect_uri?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
codeVerifier?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
code_verifier?: string;
|
||||||
|
}
|
||||||
@@ -37,6 +37,38 @@ export async function resolveOAuthIssuer(core: CoreGrpcService, fallback = 'http
|
|||||||
return normalizeBaseUrl(fallback);
|
return normalizeBaseUrl(fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveFrontendUrl(core: CoreGrpcService, fallback = 'http://localhost:3002'): Promise<string> {
|
||||||
|
const envFrontend = process.env.PUBLIC_FRONTEND_URL?.trim();
|
||||||
|
if (envFrontend) {
|
||||||
|
return normalizeBaseUrl(envFrontend);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PUBLIC_FRONTEND_URL' }))) as { value?: string };
|
||||||
|
const fromDb = response.value?.trim();
|
||||||
|
if (fromDb) {
|
||||||
|
return normalizeBaseUrl(fromDb);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback ниже
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PROJECT_DOMAIN' }))) as { value?: string };
|
||||||
|
const domain = response.value?.trim();
|
||||||
|
if (domain) {
|
||||||
|
if (domain.startsWith('http://') || domain.startsWith('https://')) {
|
||||||
|
return normalizeBaseUrl(domain);
|
||||||
|
}
|
||||||
|
return `https://${domain.replace(/^\/+/, '')}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback ниже
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeBaseUrl(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildOpenIdConfiguration(issuer: string) {
|
export function buildOpenIdConfiguration(issuer: string) {
|
||||||
const base = normalizeBaseUrl(issuer);
|
const base = normalizeBaseUrl(issuer);
|
||||||
return {
|
return {
|
||||||
@@ -49,8 +81,9 @@ export function buildOpenIdConfiguration(issuer: string) {
|
|||||||
subject_types_supported: ['public'],
|
subject_types_supported: ['public'],
|
||||||
id_token_signing_alg_values_supported: ['HS256'],
|
id_token_signing_alg_values_supported: ['HS256'],
|
||||||
scopes_supported: ['openid', 'profile', 'email', 'phone', 'address', 'documents'],
|
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'],
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||||
|
code_challenge_methods_supported: ['S256', 'plain'],
|
||||||
claims_supported: ['sub', 'email', 'phone', 'name', 'picture']
|
claims_supported: ['sub', 'email', 'phone', 'name', 'picture']
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
147
apps/api-gateway/src/lib/oauth-params.ts
Normal file
147
apps/api-gateway/src/lib/oauth-params.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
export interface NormalizedAuthorizeQuery {
|
||||||
|
userId?: string;
|
||||||
|
clientId: string;
|
||||||
|
redirectUri: string;
|
||||||
|
scope: string;
|
||||||
|
state?: string;
|
||||||
|
responseType?: string;
|
||||||
|
codeChallenge?: string;
|
||||||
|
codeChallengeMethod?: string;
|
||||||
|
nonce?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedTokenBody {
|
||||||
|
grantType: string;
|
||||||
|
code?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
redirectUri?: string;
|
||||||
|
codeVerifier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown) {
|
||||||
|
if (typeof value === 'string') return value.trim();
|
||||||
|
if (Array.isArray(value) && typeof value[0] === 'string') return value[0].trim();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAuthorizeQuery(query: Record<string, unknown>): NormalizedAuthorizeQuery {
|
||||||
|
const clientId = readString(query.clientId) ?? readString(query.client_id);
|
||||||
|
const redirectUri = readString(query.redirectUri) ?? readString(query.redirect_uri);
|
||||||
|
const scope = readString(query.scope) ?? 'openid profile';
|
||||||
|
const userId = readString(query.userId) ?? readString(query.user_id);
|
||||||
|
const state = readString(query.state);
|
||||||
|
const responseType = readString(query.response_type) ?? readString(query.responseType);
|
||||||
|
const codeChallenge = readString(query.code_challenge) ?? readString(query.codeChallenge);
|
||||||
|
const codeChallengeMethod = readString(query.code_challenge_method) ?? readString(query.codeChallengeMethod);
|
||||||
|
const nonce = readString(query.nonce);
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
throw new BadRequestException('Укажите client_id');
|
||||||
|
}
|
||||||
|
if (!redirectUri) {
|
||||||
|
throw new BadRequestException('Укажите redirect_uri');
|
||||||
|
}
|
||||||
|
if (responseType && responseType !== 'code') {
|
||||||
|
throw new BadRequestException('Поддерживается только response_type=code');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
clientId,
|
||||||
|
redirectUri,
|
||||||
|
scope,
|
||||||
|
state,
|
||||||
|
responseType,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod,
|
||||||
|
nonce
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTokenBody(body: Record<string, unknown>): NormalizedTokenBody {
|
||||||
|
const grantType = readString(body.grantType) ?? readString(body.grant_type);
|
||||||
|
const clientId = readString(body.clientId) ?? readString(body.client_id);
|
||||||
|
const clientSecret = readString(body.clientSecret) ?? readString(body.client_secret);
|
||||||
|
const code = readString(body.code);
|
||||||
|
const refreshToken = readString(body.refreshToken) ?? readString(body.refresh_token);
|
||||||
|
const redirectUri = readString(body.redirectUri) ?? readString(body.redirect_uri);
|
||||||
|
const codeVerifier = readString(body.codeVerifier) ?? readString(body.code_verifier);
|
||||||
|
|
||||||
|
if (!grantType) {
|
||||||
|
throw new BadRequestException('Укажите grant_type');
|
||||||
|
}
|
||||||
|
if (!clientId) {
|
||||||
|
throw new BadRequestException('Укажите client_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
grantType,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
code,
|
||||||
|
refreshToken,
|
||||||
|
redirectUri,
|
||||||
|
codeVerifier
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendQueryParams(baseUrl: string, query: Record<string, unknown>) {
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (value === undefined || value === null || value === '') continue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item) => url.searchParams.append(key, String(item)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBasicClientCredentials(authorization?: string) {
|
||||||
|
if (!authorization?.startsWith('Basic ')) return null;
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(authorization.slice(6), 'base64').toString('utf8');
|
||||||
|
const separator = decoded.indexOf(':');
|
||||||
|
if (separator < 0) return null;
|
||||||
|
return {
|
||||||
|
clientId: decoded.slice(0, separator),
|
||||||
|
clientSecret: decoded.slice(separator + 1)
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapTokenResponseToStandard(result: {
|
||||||
|
accessToken?: string;
|
||||||
|
tokenType?: string;
|
||||||
|
expiresIn?: number;
|
||||||
|
refreshToken?: string;
|
||||||
|
idToken?: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
access_token: result.accessToken,
|
||||||
|
token_type: result.tokenType ?? 'Bearer',
|
||||||
|
expires_in: result.expiresIn ?? 900,
|
||||||
|
refresh_token: result.refreshToken,
|
||||||
|
id_token: result.idToken
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeTokenCredentials(
|
||||||
|
body: NormalizedTokenBody,
|
||||||
|
authorization?: string
|
||||||
|
): NormalizedTokenBody {
|
||||||
|
const basic = parseBasicClientCredentials(authorization);
|
||||||
|
if (!basic) return body;
|
||||||
|
return {
|
||||||
|
...body,
|
||||||
|
clientId: body.clientId || basic.clientId,
|
||||||
|
clientSecret: body.clientSecret || basic.clientSecret
|
||||||
|
};
|
||||||
|
}
|
||||||
31
apps/docs/components/bot-code-tabs.tsx
Normal file
31
apps/docs/components/bot-code-tabs.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Базовый URL Bot API:{' '}
|
||||||
|
<code className="rounded bg-zinc-100 px-1.5 py-0.5 dark:bg-zinc-800">{apiBase}/bot{'{token}'}/{'{method}'}</code>
|
||||||
|
{' — '}
|
||||||
|
подставляется из <strong>PUBLIC_API_URL</strong> (как для OAuth).
|
||||||
|
</p>
|
||||||
|
<CodeExampleTabs examples={examples} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { DocBlock } from '@/lib/docs-pages';
|
|||||||
import { OAuthCodeTabs } from '@/components/oauth-code-tabs';
|
import { OAuthCodeTabs } from '@/components/oauth-code-tabs';
|
||||||
import { AuthLoginCodeTabs } from '@/components/auth-code-tabs';
|
import { AuthLoginCodeTabs } from '@/components/auth-code-tabs';
|
||||||
import { AuthLdapCodeTabs } from '@/components/auth-ldap-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 { ApiReferenceSection } from '@/components/api-endpoint-card';
|
||||||
import { CodeBlock } from '@/components/code-block';
|
import { CodeBlock } from '@/components/code-block';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -70,6 +71,8 @@ export function DocBlockRenderer({ block }: { block: DocBlock }) {
|
|||||||
return <AuthLoginCodeTabs />;
|
return <AuthLoginCodeTabs />;
|
||||||
case 'auth-ldap-examples':
|
case 'auth-ldap-examples':
|
||||||
return <AuthLdapCodeTabs />;
|
return <AuthLdapCodeTabs />;
|
||||||
|
case 'bot-examples':
|
||||||
|
return <BotCodeTabs />;
|
||||||
case 'api-reference':
|
case 'api-reference':
|
||||||
return <ApiReferenceSection />;
|
return <ApiReferenceSection />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { docNavigation, groupDocNavigation } from '@/lib/navigation';
|
import { docNavigation, groupDocNavigation } from '@/lib/navigation';
|
||||||
@@ -16,10 +16,16 @@ const highlights = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Code2,
|
icon: Code2,
|
||||||
title: 'OAuth 2.0',
|
title: 'OAuth 2.0 / OIDC',
|
||||||
description: 'Примеры интеграции на JavaScript, Python, PHP, Go и других языках.',
|
description: 'Стандартный OpenID Connect: Discovery, client_id, PKCE, form-urlencoded token. Примеры для PHP без доработки OidcProvider.',
|
||||||
href: '/docs/oauth'
|
href: '/docs/oauth'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Bot,
|
||||||
|
title: 'Telegram Bot API',
|
||||||
|
description: 'Telegraf, BotFather, профиль бота, setChatMenuButton и Mini Apps — совместимость с Telegram без смены кода.',
|
||||||
|
href: '/docs/bot-api'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Shield,
|
icon: Shield,
|
||||||
title: 'Безопасность',
|
title: 'Безопасность',
|
||||||
|
|||||||
@@ -31,12 +31,39 @@ export const apiReference: ApiTagGroup[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'OAuth 2.0',
|
tag: 'OAuth 2.0 / OIDC',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{ method: 'GET', path: '/.well-known/openid-configuration', summary: 'OpenID Connect Discovery' },
|
{
|
||||||
{ method: 'GET', path: '/oauth/authorize', summary: 'Создать authorization code' },
|
method: 'GET',
|
||||||
{ method: 'POST', path: '/oauth/token', summary: 'Выдать OAuth токены (code / refresh_token)' },
|
path: '/.well-known/openid-configuration',
|
||||||
{ method: 'GET', path: '/oauth/userinfo', summary: 'Профиль по OAuth access token', auth: true }
|
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}/avatar', summary: 'Обновить аватар', auth: true },
|
||||||
{ method: 'PATCH', path: '/profile/users/{userId}/contacts', summary: 'Обновить контакты', auth: true },
|
{ method: 'PATCH', path: '/profile/users/{userId}/contacts', summary: 'Обновить контакты', auth: true },
|
||||||
{ method: 'POST', path: '/profile/users/{userId}/password', 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',
|
method: 'POST',
|
||||||
path: '/profile/users/{userId}/self-delete',
|
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/groups/{groupId}/invite-search', summary: 'Поиск пользователей для приглашения', auth: true },
|
||||||
{ method: 'GET', path: '/family/invites', summary: 'Входящие приглашения', auth: true },
|
{ method: 'GET', path: '/family/invites', summary: 'Входящие приглашения', auth: true },
|
||||||
{ method: 'POST', path: '/family/invites/{inviteId}/respond', 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: 'Чат',
|
tag: 'Чат',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{ method: 'GET', path: '/chat/groups/{groupId}/rooms', summary: 'Список чатов семьи', auth: true },
|
{
|
||||||
{ method: 'POST', path: '/chat/rooms/{roomId}/messages', summary: 'Отправить сообщение', auth: true },
|
method: 'GET',
|
||||||
{ method: 'GET', path: '/chat/rooms/{roomId}/messages', summary: 'Сообщения чата', auth: true }
|
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 }
|
{ 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: 'Уведомления',
|
tag: 'Уведомления',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
|
|||||||
186
apps/docs/lib/bot-examples.ts
Normal file
186
apps/docs/lib/bot-examples.ts
Normal file
@@ -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;`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,11 @@ export const docNavigation: DocNavItem[] = [
|
|||||||
{ slug: 'architecture', title: 'Архитектура', group: 'Введение' },
|
{ slug: 'architecture', title: 'Архитектура', group: 'Введение' },
|
||||||
{ slug: 'deployment', title: 'Развёртывание на сервере', group: 'Введение' },
|
{ slug: 'deployment', title: 'Развёртывание на сервере', group: 'Введение' },
|
||||||
{ slug: 'authentication', 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: 'ldap', title: 'LDAP / LDAPS', group: 'Интеграция' },
|
||||||
{ slug: 'sessions', title: 'Сессии, PIN и удаление аккаунта', group: 'Безопасность' },
|
{ slug: 'sessions', title: 'Сессии, PIN и удаление аккаунта', group: 'Безопасность' },
|
||||||
{ slug: 'family-chat', title: 'Семья и чат', group: 'Функции' },
|
{ slug: 'family-chat', title: 'Семья и чат', group: 'Функции' },
|
||||||
|
{ slug: 'bot-api', title: 'Telegram Bot API', group: 'Интеграция' },
|
||||||
{ slug: 'api-reference', title: 'Справочник API', group: 'Справочник' }
|
{ slug: 'api-reference', title: 'Справочник API', group: 'Справочник' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function buildOAuthExamples(apiBase: string): OAuthExample[] {
|
|||||||
id: 'javascript',
|
id: 'javascript',
|
||||||
label: 'JavaScript',
|
label: 'JavaScript',
|
||||||
language: 'javascript',
|
language: 'javascript',
|
||||||
code: `// Authorization Code Flow (Node.js / браузер)
|
code: `// Authorization Code Flow — стандартный OIDC (RFC 6749)
|
||||||
const clientId = 'YOUR_CLIENT_ID';
|
const clientId = 'YOUR_CLIENT_ID';
|
||||||
const redirectUri = 'https://app.example.com/oauth/callback';
|
const redirectUri = 'https://app.example.com/oauth/callback';
|
||||||
const scope = 'openid profile email';
|
const scope = 'openid profile email';
|
||||||
@@ -21,34 +21,63 @@ const state = crypto.randomUUID();
|
|||||||
|
|
||||||
// Шаг 1: перенаправить пользователя на IdP
|
// Шаг 1: перенаправить пользователя на IdP
|
||||||
const authorizeUrl = new URL('${API_BASE}/oauth/authorize');
|
const authorizeUrl = new URL('${API_BASE}/oauth/authorize');
|
||||||
authorizeUrl.searchParams.set('userId', 'USER_ID_AFTER_LOGIN');
|
authorizeUrl.searchParams.set('client_id', clientId);
|
||||||
authorizeUrl.searchParams.set('clientId', clientId);
|
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
||||||
authorizeUrl.searchParams.set('redirectUri', redirectUri);
|
authorizeUrl.searchParams.set('response_type', 'code');
|
||||||
authorizeUrl.searchParams.set('scope', scope);
|
authorizeUrl.searchParams.set('scope', scope);
|
||||||
authorizeUrl.searchParams.set('state', state);
|
authorizeUrl.searchParams.set('state', state);
|
||||||
window.location.href = authorizeUrl.toString();
|
window.location.href = authorizeUrl.toString();
|
||||||
|
|
||||||
// OIDC Discovery (issuer = PUBLIC_API_URL из настроек админки)
|
// OIDC Discovery
|
||||||
// GET ${API_BASE}/.well-known/openid-configuration
|
// GET ${API_BASE}/.well-known/openid-configuration
|
||||||
|
|
||||||
// Шаг 2: обменять code на токены (на backend!)
|
// Шаг 2: обменять code на токены (на backend!)
|
||||||
const tokenResponse = await fetch('${API_BASE}/oauth/token', {
|
const tokenResponse = await fetch('${API_BASE}/oauth/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: JSON.stringify({
|
body: new URLSearchParams({
|
||||||
grantType: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code: 'AUTHORIZATION_CODE',
|
code: 'AUTHORIZATION_CODE',
|
||||||
clientId,
|
client_id: clientId,
|
||||||
clientSecret: 'YOUR_CLIENT_SECRET',
|
client_secret: 'YOUR_CLIENT_SECRET',
|
||||||
redirectUri
|
redirect_uri: redirectUri
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const tokens = await tokenResponse.json();
|
const tokens = await tokenResponse.json();
|
||||||
|
// tokens.access_token, tokens.id_token, tokens.refresh_token
|
||||||
|
|
||||||
// Шаг 3: получить профиль
|
// Шаг 3: получить профиль
|
||||||
const profile = await fetch('${API_BASE}/oauth/userinfo', {
|
const profile = await fetch('${API_BASE}/oauth/userinfo', {
|
||||||
headers: { Authorization: \`Bearer \${tokens.accessToken}\` }
|
headers: { Authorization: \`Bearer \${tokens.access_token}\` }
|
||||||
}).then((r) => r.json());`
|
}).then((r) => r.json());`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'php',
|
||||||
|
label: 'PHP (OIDC)',
|
||||||
|
language: 'php',
|
||||||
|
code: `<?php
|
||||||
|
// composer require jumbojett/openid-connect-php
|
||||||
|
require 'vendor/autoload.php';
|
||||||
|
|
||||||
|
use Jumbojett\\OpenIDConnectClient;
|
||||||
|
|
||||||
|
$issuer = '${API_BASE}'; // PUBLIC_API_URL из админки Lendry ID
|
||||||
|
$clientId = getenv('OAUTH_CLIENT_ID');
|
||||||
|
$clientSecret = getenv('OAUTH_CLIENT_SECRET');
|
||||||
|
$redirectUri = 'https://app.example.com/oauth/callback';
|
||||||
|
|
||||||
|
$oidc = new OpenIDConnectClient($issuer, $clientId, $clientSecret);
|
||||||
|
$oidc->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',
|
id: 'typescript-next',
|
||||||
@@ -65,19 +94,19 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const tokenRes = await fetch(\`\${ISSUER}/oauth/token\`, {
|
const tokenRes = await fetch(\`\${ISSUER}/oauth/token\`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: JSON.stringify({
|
body: new URLSearchParams({
|
||||||
grantType: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code,
|
code,
|
||||||
clientId: process.env.OAUTH_CLIENT_ID,
|
client_id: process.env.OAUTH_CLIENT_ID!,
|
||||||
clientSecret: process.env.OAUTH_CLIENT_SECRET,
|
client_secret: process.env.OAUTH_CLIENT_SECRET!,
|
||||||
redirectUri: process.env.OAUTH_REDIRECT_URI
|
redirect_uri: process.env.OAUTH_REDIRECT_URI!
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokens = await tokenRes.json();
|
const tokens = await tokenRes.json();
|
||||||
const response = NextResponse.redirect('/dashboard');
|
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;
|
return response;
|
||||||
}`
|
}`
|
||||||
},
|
},
|
||||||
@@ -86,125 +115,61 @@ export async function GET(request: NextRequest) {
|
|||||||
label: 'Python',
|
label: 'Python',
|
||||||
language: 'python',
|
language: 'python',
|
||||||
code: `import requests
|
code: `import requests
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
API_BASE = '${API_BASE}'
|
API_BASE = '${API_BASE}'
|
||||||
CLIENT_ID = 'YOUR_CLIENT_ID'
|
CLIENT_ID = 'YOUR_CLIENT_ID'
|
||||||
CLIENT_SECRET = 'YOUR_CLIENT_SECRET'
|
CLIENT_SECRET = 'YOUR_CLIENT_SECRET'
|
||||||
REDIRECT_URI = 'https://app.example.com/oauth/callback'
|
REDIRECT_URI = 'https://app.example.com/oauth/callback'
|
||||||
|
|
||||||
# OIDC Discovery
|
|
||||||
discovery = requests.get(f'{API_BASE}/.well-known/openid-configuration', timeout=15).json()
|
discovery = requests.get(f'{API_BASE}/.well-known/openid-configuration', timeout=15).json()
|
||||||
|
|
||||||
params = urlencode({
|
# Authorization URL — стандартные параметры, userId не нужен
|
||||||
'userId': 'USER_ID',
|
authorize_url = (
|
||||||
'clientId': CLIENT_ID,
|
f"{discovery['authorization_endpoint']}"
|
||||||
'redirectUri': REDIRECT_URI,
|
f"?client_id={CLIENT_ID}"
|
||||||
'scope': 'openid profile email',
|
f"&redirect_uri={requests.utils.quote(REDIRECT_URI, safe='')}"
|
||||||
'state': 'random-state'
|
f"&response_type=code"
|
||||||
})
|
f"&scope=openid%20profile%20email"
|
||||||
authorize_url = f'{API_BASE}/oauth/authorize?{params}'
|
f"&state=random-state"
|
||||||
|
)
|
||||||
|
|
||||||
token_response = requests.post(f'{API_BASE}/oauth/token', json={
|
token_response = requests.post(
|
||||||
'grantType': 'authorization_code',
|
discovery['token_endpoint'],
|
||||||
'code': 'AUTHORIZATION_CODE',
|
data={
|
||||||
'clientId': CLIENT_ID,
|
'grant_type': 'authorization_code',
|
||||||
'clientSecret': CLIENT_SECRET,
|
'code': 'AUTHORIZATION_CODE',
|
||||||
'redirectUri': REDIRECT_URI
|
'client_id': CLIENT_ID,
|
||||||
}, timeout=15)
|
'client_secret': CLIENT_SECRET,
|
||||||
|
'redirect_uri': REDIRECT_URI,
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
tokens = token_response.json()
|
tokens = token_response.json()
|
||||||
|
|
||||||
profile = requests.get(
|
profile = requests.get(
|
||||||
f'{API_BASE}/oauth/userinfo',
|
discovery['userinfo_endpoint'],
|
||||||
headers={'Authorization': f"Bearer {tokens['accessToken']}"},
|
headers={'Authorization': f"Bearer {tokens['access_token']}"},
|
||||||
timeout=15
|
timeout=15,
|
||||||
).json()`
|
).json()`
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'php',
|
|
||||||
label: 'PHP',
|
|
||||||
language: 'php',
|
|
||||||
code: `<?php
|
|
||||||
$apiBase = '${API_BASE}'; // issuer = PUBLIC_API_URL из админки
|
|
||||||
$clientId = getenv('OAUTH_CLIENT_ID');
|
|
||||||
$clientSecret = getenv('OAUTH_CLIENT_SECRET');
|
|
||||||
$redirectUri = 'https://app.example.com/oauth/callback';
|
|
||||||
|
|
||||||
// OIDC Discovery — используйте issuer, а не authorization endpoint
|
|
||||||
$discovery = json_decode(file_get_contents($apiBase . '/.well-known/openid-configuration'), true);
|
|
||||||
|
|
||||||
$params = http_build_query([
|
|
||||||
'userId' => '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<Dictionary<string, object>>(
|
|
||||||
$"{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',
|
id: 'curl',
|
||||||
label: 'cURL',
|
label: 'cURL',
|
||||||
language: 'bash',
|
language: 'bash',
|
||||||
code: `# OIDC Discovery (issuer = ${API_BASE})
|
code: `# OIDC Discovery
|
||||||
curl ${API_BASE}/.well-known/openid-configuration
|
curl ${API_BASE}/.well-known/openid-configuration
|
||||||
|
|
||||||
# Authorization (браузер пользователя)
|
# Authorization (браузер пользователя, стандартный OIDC)
|
||||||
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"
|
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 \\
|
curl -X POST ${API_BASE}/oauth/token \\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/x-www-form-urlencoded" \\
|
||||||
-d '{
|
-d "grant_type=authorization_code" \\
|
||||||
"grantType": "authorization_code",
|
-d "code=AUTHORIZATION_CODE" \\
|
||||||
"code": "AUTHORIZATION_CODE",
|
-d "client_id=CLIENT_ID" \\
|
||||||
"clientId": "CLIENT_ID",
|
-d "client_secret=CLIENT_SECRET" \\
|
||||||
"clientSecret": "CLIENT_SECRET",
|
-d "redirect_uri=https://app.example.com/callback"
|
||||||
"redirectUri": "https://app.example.com/callback"
|
|
||||||
}'
|
|
||||||
|
|
||||||
# UserInfo
|
# UserInfo
|
||||||
curl ${API_BASE}/oauth/userinfo \\
|
curl ${API_BASE}/oauth/userinfo \\
|
||||||
|
|||||||
@@ -39,3 +39,18 @@ export function buildOAuthEndpoints(apiBase: string): OAuthEndpoints {
|
|||||||
jwksUrl: `${base}/.well-known/jwks.json`
|
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();
|
||||||
|
}
|
||||||
|
|||||||
234
apps/frontend/app/admin/bots/page.tsx
Normal file
234
apps/frontend/app/admin/bots/page.tsx
Normal file
@@ -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<ManagedBot[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [metrics, setMetrics] = useState<AdminBotMetrics | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionBotId, setActionBotId] = useState<string | null>(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 (
|
||||||
|
<AdminShell active="/admin/bots">
|
||||||
|
<h2 className="mb-4 text-2xl font-medium tracking-tight">Telegram-боты</h2>
|
||||||
|
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<MetricCard label="Всего ботов" value={metrics?.totalBots ?? 0} icon={Bot} />
|
||||||
|
<MetricCard label="Активных" value={metrics?.activeBots ?? 0} icon={CheckCircle2} accent="text-emerald-600" />
|
||||||
|
<MetricCard label="Заблокированных" value={metrics?.blockedBots ?? 0} icon={Ban} accent="text-rose-600" />
|
||||||
|
<MetricCard label="Сообщений" value={metrics?.totalMessages ?? 0} icon={MessageSquare} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative min-w-[240px] flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
|
||||||
|
<Input
|
||||||
|
className="rounded-xl pl-9"
|
||||||
|
placeholder="Поиск по названию или @username"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#667085]">Найдено: {total}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-[#eceef4] bg-white">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Бот</TableHead>
|
||||||
|
<TableHead>Владелец</TableHead>
|
||||||
|
<TableHead>Статус</TableHead>
|
||||||
|
<TableHead className="text-right">Действия</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="py-10 text-center text-[#667085]">
|
||||||
|
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : bots.length ? (
|
||||||
|
bots.map((bot) => (
|
||||||
|
<TableRow key={bot.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
|
||||||
|
<Bot className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{bot.name}</p>
|
||||||
|
<p className="text-sm text-[#667085]">@{bot.username}</p>
|
||||||
|
{bot.isSystemBot ? <p className="text-xs text-[#3390ec]">Системный</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4 text-[#667085]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{bot.owner?.displayName ?? '—'}</p>
|
||||||
|
{bot.owner?.username ? <p className="text-xs text-[#667085]">@{bot.owner.username}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
bot.isActive
|
||||||
|
? 'inline-flex rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700'
|
||||||
|
: 'inline-flex rounded-full bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{bot.isActive ? 'Активен' : 'Заблокирован'}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{!bot.isSystemBot ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
disabled={actionBotId === bot.id}
|
||||||
|
onClick={() => void handleToggleActive(bot)}
|
||||||
|
>
|
||||||
|
{actionBotId === bot.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : bot.isActive ? (
|
||||||
|
'Заблокировать'
|
||||||
|
) : (
|
||||||
|
'Разблокировать'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[#667085]">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="py-10 text-center text-[#667085]">
|
||||||
|
Боты не найдены
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 ? (
|
||||||
|
<div className="mt-4 flex items-center justify-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="rounded-xl" disabled={page <= 1} onClick={() => setPage((value) => value - 1)}>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-[#667085]">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((value) => value + 1)}
|
||||||
|
>
|
||||||
|
Вперёд
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
accent
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
icon: typeof Bot;
|
||||||
|
accent?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-[#eceef4] bg-white p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm text-[#667085]">
|
||||||
|
<Icon className={`h-4 w-4 ${accent ?? ''}`} />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-semibold">{value.toLocaleString('ru-RU')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -82,6 +82,16 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const router = useRouter();
|
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 { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, beginTotpLogin, verifyTotpLogin, completePin, applyLoginAuth } = useAuth();
|
||||||
|
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
@@ -174,7 +184,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
router.push('/');
|
finishLoginRedirect();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,7 +606,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
await completePin(pendingSessionId, pin);
|
await completePin(pendingSessionId, pin);
|
||||||
|
|
||||||
router.push('/');
|
finishLoginRedirect();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
||||||
|
|||||||
150
apps/frontend/app/auth/oauth/authorize/page.tsx
Normal file
150
apps/frontend/app/auth/oauth/authorize/page.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientId || !redirectUri) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md px-4 py-16 text-center">
|
||||||
|
<h1 className="text-xl font-semibold">Некорректный OAuth запрос</h1>
|
||||||
|
<p className="mt-3 text-sm text-[#667085]">Отсутствуют обязательные параметры client_id и redirect_uri.</p>
|
||||||
|
<Link href="/" className="mt-6 inline-block text-[#3390ec] hover:underline">
|
||||||
|
На главную
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user || !token || isPinLocked) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex min-h-[70vh] max-w-lg flex-col justify-center px-4 py-12">
|
||||||
|
<div className="mb-8 flex justify-center">
|
||||||
|
<BrandLogo />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border border-[#eceef4] bg-white p-6 shadow-sm">
|
||||||
|
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
|
||||||
|
<ShieldCheck className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold">Разрешить доступ?</h1>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-[#667085]">
|
||||||
|
Приложение <span className="font-medium text-[#1f2430]">{clientId}</span> запрашивает доступ к вашему аккаунту Lendry ID.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-2 rounded-2xl bg-[#f4f5f8] p-4 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="text-[#667085]">Пользователь:</span> {user.displayName}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-[#667085]">Scopes:</span> {scope}
|
||||||
|
</p>
|
||||||
|
<p className="break-all">
|
||||||
|
<span className="text-[#667085]">Redirect URI:</span> {redirectUri}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{error ? <p className="mt-4 text-sm text-red-600">{error}</p> : null}
|
||||||
|
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Button className="flex-1 rounded-xl" disabled={submitting} onClick={() => void approve()}>
|
||||||
|
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
Разрешить
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="flex-1 rounded-xl" disabled={submitting} onClick={() => router.push('/')}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OAuthAuthorizePage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<OAuthAuthorizeContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,17 +5,22 @@ import { FamilyGroupView } from '@/components/family/family-group-view';
|
|||||||
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
|
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
|
||||||
import { IdShell } from '@/components/id/shell';
|
import { IdShell } from '@/components/id/shell';
|
||||||
|
|
||||||
export default function FamilyGroupPage({ params }: { params: Promise<{ groupId: string }> }) {
|
function FamilyGroupPageContent({ groupId }: { groupId: string }) {
|
||||||
const { groupId } = use(params);
|
|
||||||
const { setSelectedGroupId } = useFamilyOverlay();
|
const { setSelectedGroupId } = useFamilyOverlay();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedGroupId(groupId);
|
setSelectedGroupId(groupId);
|
||||||
}, [groupId, setSelectedGroupId]);
|
}, [groupId, setSelectedGroupId]);
|
||||||
|
|
||||||
|
return <FamilyGroupView groupId={groupId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FamilyGroupPage({ params }: { params: Promise<{ groupId: string }> }) {
|
||||||
|
const { groupId } = use(params);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IdShell active="/family" wide>
|
<IdShell active="/family" wide>
|
||||||
<FamilyGroupView groupId={groupId} />
|
<FamilyGroupPageContent groupId={groupId} />
|
||||||
</IdShell>
|
</IdShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,20 @@ input {
|
|||||||
background: var(--muted);
|
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 {
|
.id-shadow {
|
||||||
box-shadow: 0 24px 70px rgb(22 26 43 / 12%);
|
box-shadow: 0 24px 70px rgb(22 26 43 / 12%);
|
||||||
}
|
}
|
||||||
|
|||||||
181
apps/frontend/app/mini-apps/bot-create/content.tsx
Normal file
181
apps/frontend/app/mini-apps/bot-create/content.tsx
Normal file
@@ -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<Step>('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 (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-6 text-center text-sm text-[#667085]">
|
||||||
|
Войдите в Lendry ID, чтобы создать бота
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#17212b] p-4 text-white">
|
||||||
|
<div className="mx-auto max-w-md space-y-5 rounded-[24px] bg-[#242f3d] p-5 shadow-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#3390ec]/20 text-[#3390ec]">
|
||||||
|
<Bot className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">Создание бота</h1>
|
||||||
|
<p className="text-sm text-[#8b93a7]">Как в BotFather Telegram</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-[#8b93a7]">
|
||||||
|
{(['name', 'username', 'done'] as Step[]).map((item, index) => (
|
||||||
|
<div key={item} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex h-6 w-6 items-center justify-center rounded-full',
|
||||||
|
step === item || (step === 'done' && item !== 'done') || (item === 'name' && step !== 'name')
|
||||||
|
? 'bg-[#3390ec] text-white'
|
||||||
|
: 'bg-[#17212b] text-[#8b93a7]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step === 'done' && item !== 'done' ? <Check className="h-3.5 w-3.5" /> : index + 1}
|
||||||
|
</span>
|
||||||
|
{index < 2 ? <span className="h-px w-8 bg-[#2a3544]" /> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 'name' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm leading-relaxed text-[#c5cad3]">
|
||||||
|
Alright, a new bot. How are we going to call it? Please choose a name for your bot.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[#8b93a7]">Хорошо, новый бот. Как мы его назовём? Выберите название.</p>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="Например: Сервис уведомлений"
|
||||||
|
className="rounded-xl border-[#2a3544] bg-[#17212b] text-white placeholder:text-[#667085]"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button className="w-full rounded-xl" disabled={!name.trim()} onClick={() => setStep('username')}>
|
||||||
|
Далее
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 'username' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm leading-relaxed text-[#c5cad3]">
|
||||||
|
Good. Now let's choose a username for your bot. It must end in `bot`.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[#8b93a7]">Username должен заканчиваться на `_bot` (суффикс добавится автоматически).</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[#8b93a7]">@</span>
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#8b93a7]">Будет: @{usernamePreview || 'your_bot'}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" className="flex-1 rounded-xl bg-[#17212b] text-white hover:bg-[#1c2733]" onClick={() => setStep('name')}>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1 rounded-xl" disabled={creating || username.replace(/_bot$/i, '').trim().length < 5} onClick={() => void handleCreate()}>
|
||||||
|
{creating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
Создать бота
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 'done' && createdBot ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl bg-[#17212b] p-4">
|
||||||
|
<p className="text-sm text-[#8b93a7]">Done! Congratulations on your new bot.</p>
|
||||||
|
<p className="mt-2 text-base font-semibold">@{createdBot.username}</p>
|
||||||
|
<p className="mt-3 text-sm text-red-300">Сохраните токен — он больше не будет показан:</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Input value={createdBot.token} readOnly className="rounded-xl border-[#2a3544] bg-[#242f3d] font-mono text-xs text-white" />
|
||||||
|
<Button type="button" variant="secondary" className="rounded-xl bg-[#3390ec] text-white hover:bg-[#2b7fd4]" onClick={() => void copyToken()}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full rounded-xl"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setStep('name');
|
||||||
|
setName('');
|
||||||
|
setUsername('');
|
||||||
|
setCreatedBot(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Создать ещё одного бота
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
apps/frontend/app/mini-apps/bot-create/page.tsx
Normal file
16
apps/frontend/app/mini-apps/bot-create/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import { BotCreateMiniAppContent } from './content';
|
||||||
|
|
||||||
|
export default function BotCreateMiniAppPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-6 text-sm text-[#667085]">Загрузка...</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BotCreateMiniAppContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
371
apps/frontend/app/mini-apps/bot-manage/content.tsx
Normal file
371
apps/frontend/app/mini-apps/bot-manage/content.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">{label}</label>
|
||||||
|
{hint ? <p className="text-xs text-[#667085]">{hint}</p> : null}
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
maxLength={maxLength}
|
||||||
|
rows={rows}
|
||||||
|
className="w-full resize-y rounded-xl border border-[#dce3ec] bg-white px-3 py-2 text-sm outline-none transition focus:border-[#3390ec] focus:ring-2 focus:ring-[#3390ec]/15"
|
||||||
|
/>
|
||||||
|
{maxLength ? <p className="text-right text-xs text-[#a8adbc]">{value.length}/{maxLength}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionCard({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-4 rounded-[20px] border border-[#eceef4] bg-[#fafbfc] p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-[#3390ec]/10 text-[#3390ec]">
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">{title}</h2>
|
||||||
|
<p className="text-sm text-[#667085]">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BotManageMiniAppContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const botId = searchParams.get('botId') ?? '';
|
||||||
|
const { token, user, isPinLocked } = useAuth();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [savingBasic, setSavingBasic] = useState(false);
|
||||||
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
|
const [revoking, setRevoking] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [aboutText, setAboutText] = useState('');
|
||||||
|
const [botPicUrl, setBotPicUrl] = useState('');
|
||||||
|
const [menuButtonUrl, setMenuButtonUrl] = useState('');
|
||||||
|
const [menuButtonText, setMenuButtonText] = useState('App');
|
||||||
|
const [tokenPrefix, setTokenPrefix] = useState('');
|
||||||
|
const [newToken, setNewToken] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canUse = Boolean(token && user && !isPinLocked && botId);
|
||||||
|
|
||||||
|
const loadBot = useCallback(async () => {
|
||||||
|
if (!canUse) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const bot = await fetchBot(botId, token);
|
||||||
|
setName(bot.name);
|
||||||
|
setUsername(bot.username);
|
||||||
|
setDescription(bot.description ?? '');
|
||||||
|
setAboutText(bot.aboutText ?? '');
|
||||||
|
setBotPicUrl(bot.botPicUrl ?? '');
|
||||||
|
setTokenPrefix(bot.tokenPrefix ?? '');
|
||||||
|
|
||||||
|
const menuButton = parseComposerMenuButtonJson(bot.menuButtonJson);
|
||||||
|
if (menuButton) {
|
||||||
|
setMenuButtonUrl(menuButton.web_app.url);
|
||||||
|
setMenuButtonText(menuButton.text);
|
||||||
|
} else {
|
||||||
|
setMenuButtonUrl('');
|
||||||
|
setMenuButtonText('App');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast(getApiErrorMessage(error, 'Не удалось загрузить бота') ?? 'Ошибка');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [botId, canUse, showToast, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadBot();
|
||||||
|
}, [loadBot]);
|
||||||
|
|
||||||
|
const usernameWithoutSuffix = useMemo(() => username.replace(/_bot$/i, ''), [username]);
|
||||||
|
|
||||||
|
async function handleSaveBasic() {
|
||||||
|
if (!canUse) return;
|
||||||
|
setSavingBasic(true);
|
||||||
|
try {
|
||||||
|
const bot = await updateManagedBot(
|
||||||
|
botId,
|
||||||
|
{
|
||||||
|
name: name.trim(),
|
||||||
|
username: usernameWithoutSuffix.trim()
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
setName(bot.name);
|
||||||
|
setUsername(bot.username);
|
||||||
|
showToast('Основные настройки сохранены');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(getApiErrorMessage(error, 'Не удалось сохранить настройки') ?? 'Ошибка');
|
||||||
|
} finally {
|
||||||
|
setSavingBasic(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveProfile() {
|
||||||
|
if (!canUse) return;
|
||||||
|
setSavingProfile(true);
|
||||||
|
try {
|
||||||
|
const bot = await updateManagedBotProfile(
|
||||||
|
botId,
|
||||||
|
{
|
||||||
|
description: description.trim(),
|
||||||
|
aboutText: aboutText.trim(),
|
||||||
|
botPicUrl: botPicUrl.trim(),
|
||||||
|
menuButtonUrl: menuButtonUrl.trim(),
|
||||||
|
menuButtonText: menuButtonText.trim() || 'App'
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
setDescription(bot.description ?? '');
|
||||||
|
setAboutText(bot.aboutText ?? '');
|
||||||
|
setBotPicUrl(bot.botPicUrl ?? '');
|
||||||
|
const menuButton = parseComposerMenuButtonJson(bot.menuButtonJson);
|
||||||
|
if (menuButton) {
|
||||||
|
setMenuButtonUrl(menuButton.web_app.url);
|
||||||
|
setMenuButtonText(menuButton.text);
|
||||||
|
}
|
||||||
|
showToast('Профиль бота обновлён');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(getApiErrorMessage(error, 'Не удалось обновить профиль') ?? 'Ошибка');
|
||||||
|
} finally {
|
||||||
|
setSavingProfile(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevokeToken() {
|
||||||
|
if (!canUse) return;
|
||||||
|
setRevoking(true);
|
||||||
|
try {
|
||||||
|
const response = await revokeManagedBotToken(botId, token);
|
||||||
|
setNewToken(response.token ?? null);
|
||||||
|
setTokenPrefix(response.bot?.tokenPrefix ?? tokenPrefix);
|
||||||
|
showToast('Токен перевыпущен');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(getApiErrorMessage(error, 'Не удалось перевыпустить токен') ?? 'Ошибка');
|
||||||
|
} finally {
|
||||||
|
setRevoking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToken() {
|
||||||
|
if (!newToken) return;
|
||||||
|
await navigator.clipboard.writeText(newToken);
|
||||||
|
showToast('Токен скопирован');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!botId) {
|
||||||
|
return <div className="flex min-h-screen items-center justify-center p-6 text-sm text-[#667085]">Не указан botId</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canUse) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-6 text-center text-sm text-[#667085]">
|
||||||
|
Войдите в Lendry ID, чтобы управлять ботом
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#f4f5f8] p-4 pb-8">
|
||||||
|
<div className="mx-auto max-w-lg space-y-4">
|
||||||
|
<div className="rounded-[24px] bg-white p-5 shadow-sm">
|
||||||
|
<h1 className="text-lg font-semibold">Настройки бота</h1>
|
||||||
|
<p className="text-sm text-[#667085]">Интерактивное управление профилем — аналог команд BotFather</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 rounded-[24px] bg-white p-8 text-sm text-[#667085] shadow-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4 rounded-[24px] bg-white p-5 shadow-sm">
|
||||||
|
<SectionCard icon={UserCircle2} title="Основное" description="Название и username бота (/newbot)">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Название</label>
|
||||||
|
<Input value={name} onChange={(event) => setName(event.target.value)} className="rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Username</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-[#667085]">@</span>
|
||||||
|
<Input
|
||||||
|
value={usernameWithoutSuffix}
|
||||||
|
onChange={(event) => setUsername(`${event.target.value.replace(/_bot$/i, '')}_bot`)}
|
||||||
|
className="rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button className="w-full rounded-xl" disabled={savingBasic} onClick={() => void handleSaveBasic()}>
|
||||||
|
{savingBasic ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
Сохранить основное
|
||||||
|
</Button>
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-[24px] bg-white p-5 shadow-sm">
|
||||||
|
<SectionCard
|
||||||
|
icon={TextQuote}
|
||||||
|
title="Профиль"
|
||||||
|
description="Описание и краткий текст — /setdescription и /setabouttext"
|
||||||
|
>
|
||||||
|
<FieldTextarea
|
||||||
|
label="Описание (/setdescription)"
|
||||||
|
hint="Показывается пользователю перед началом диалога"
|
||||||
|
value={description}
|
||||||
|
onChange={setDescription}
|
||||||
|
maxLength={512}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<FieldTextarea
|
||||||
|
label="О боте (/setabouttext)"
|
||||||
|
hint="Краткое описание на странице профиля бота"
|
||||||
|
value={aboutText}
|
||||||
|
onChange={setAboutText}
|
||||||
|
maxLength={120}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard icon={ImageIcon} title="Аватар" description="URL изображения — /setuserpic">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
value={botPicUrl}
|
||||||
|
onChange={(event) => setBotPicUrl(event.target.value)}
|
||||||
|
placeholder="https://cdn.example.com/avatar.png"
|
||||||
|
className="rounded-xl"
|
||||||
|
/>
|
||||||
|
{botPicUrl.trim() ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={botPicUrl.trim()}
|
||||||
|
alt="Аватар бота"
|
||||||
|
className="h-16 w-16 rounded-full border border-[#dce3ec] object-cover"
|
||||||
|
onError={(event) => {
|
||||||
|
event.currentTarget.style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard
|
||||||
|
icon={LayoutGrid}
|
||||||
|
title="Кнопка меню"
|
||||||
|
description="Глобальная Web App-кнопка слева от поля ввода — /setmenubutton"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">URL Mini App</label>
|
||||||
|
<Input
|
||||||
|
value={menuButtonUrl}
|
||||||
|
onChange={(event) => setMenuButtonUrl(event.target.value)}
|
||||||
|
placeholder="https://app.example.com/webapp"
|
||||||
|
className="rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Текст кнопки</label>
|
||||||
|
<Input
|
||||||
|
value={menuButtonText}
|
||||||
|
onChange={(event) => setMenuButtonText(event.target.value)}
|
||||||
|
placeholder="App"
|
||||||
|
className="rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#667085]">Оставьте URL пустым, чтобы сбросить кнопку меню.</p>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<Button className="w-full rounded-xl" disabled={savingProfile} onClick={() => void handleSaveProfile()}>
|
||||||
|
{savingProfile ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
Сохранить профиль
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-[24px] bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="font-semibold">Токен Bot API</h2>
|
||||||
|
<div className="rounded-2xl bg-[#f4f5f8] px-3 py-3 text-sm text-[#667085]">
|
||||||
|
Текущий префикс: <span className="font-mono text-[#1f2430]">{tokenPrefix || '—'}…</span>
|
||||||
|
</div>
|
||||||
|
{newToken ? (
|
||||||
|
<div className="space-y-2 rounded-2xl border border-[#dce3ec] p-3">
|
||||||
|
<p className="text-sm font-medium text-red-600">Сохраните новый токен — он больше не будет показан</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input value={newToken} readOnly className="rounded-xl font-mono text-xs" />
|
||||||
|
<Button type="button" variant="secondary" className="rounded-xl" onClick={() => void copyToken()}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Button variant="secondary" className="w-full rounded-xl" disabled={revoking} onClick={() => void handleRevokeToken()}>
|
||||||
|
{revoking ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
||||||
|
Перевыпустить токен
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[20px] border border-dashed border-[#dce3ec] bg-white/70 p-4 text-xs leading-relaxed text-[#667085]">
|
||||||
|
<p className="mb-2 font-medium text-[#1f2430]">Эквивалент команд BotFather</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
<li>/setdescription — поле «Описание»</li>
|
||||||
|
<li>/setabouttext — поле «О боте»</li>
|
||||||
|
<li>/setuserpic — URL аватара</li>
|
||||||
|
<li>/setmenubutton — URL и текст кнопки меню</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/frontend/app/mini-apps/bot-manage/page.tsx
Normal file
20
apps/frontend/app/mini-apps/bot-manage/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { BotManageMiniAppContent } from './content';
|
||||||
|
|
||||||
|
function BotManageFallback() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-6 text-sm text-[#667085]">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Загрузка Mini App...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BotManageMiniAppPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<BotManageFallback />}>
|
||||||
|
<BotManageMiniAppContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Copy } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger
|
||||||
|
} from '@/components/ui/context-menu';
|
||||||
|
|
||||||
|
interface BotChatMessageContextMenuProps {
|
||||||
|
text?: string;
|
||||||
|
onCopy?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BotChatMessageContextMenu({ text, onCopy, children }: BotChatMessageContextMenuProps) {
|
||||||
|
const copyText = text?.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="min-w-[200px]">
|
||||||
|
{copyText ? (
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(copyText);
|
||||||
|
onCopy?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Копировать текст
|
||||||
|
</ContextMenuItem>
|
||||||
|
) : null}
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
apps/frontend/components/chat/bot-inline-keyboard.tsx
Normal file
122
apps/frontend/components/chat/bot-inline-keyboard.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { InlineKeyboardButton, parseInlineKeyboardMarkup } from '@/lib/bot-reply-markup';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function buildInlineButtonKey(messageId: number, rowIndex: number, buttonIndex: number) {
|
||||||
|
return `${messageId}:${rowIndex}:${buttonIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BotInlineKeyboardProps {
|
||||||
|
markup: unknown;
|
||||||
|
messageId: number;
|
||||||
|
loadingButtonKey?: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
onCallbackClick?: (callbackData: string, buttonKey: string) => void | Promise<void>;
|
||||||
|
onOpenMiniApp?: (url: string) => void;
|
||||||
|
onOpenUrl?: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BotInlineKeyboard({
|
||||||
|
markup,
|
||||||
|
messageId,
|
||||||
|
loadingButtonKey = null,
|
||||||
|
disabled = false,
|
||||||
|
onCallbackClick,
|
||||||
|
onOpenMiniApp,
|
||||||
|
onOpenUrl
|
||||||
|
}: BotInlineKeyboardProps) {
|
||||||
|
const rows = parseInlineKeyboardMarkup(markup);
|
||||||
|
if (!rows.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<div
|
||||||
|
key={`row-${rowIndex}`}
|
||||||
|
className={cn('flex gap-1.5', row.length === 1 ? 'flex-col' : 'flex-row flex-wrap')}
|
||||||
|
>
|
||||||
|
{row.map((button, buttonIndex) => (
|
||||||
|
<InlineKeyboardButtonView
|
||||||
|
key={`btn-${rowIndex}-${buttonIndex}`}
|
||||||
|
button={button}
|
||||||
|
buttonKey={buildInlineButtonKey(messageId, rowIndex, buttonIndex)}
|
||||||
|
fullWidth={row.length === 1}
|
||||||
|
loading={loadingButtonKey === buildInlineButtonKey(messageId, rowIndex, buttonIndex)}
|
||||||
|
disabled={disabled || (loadingButtonKey !== null && loadingButtonKey !== buildInlineButtonKey(messageId, rowIndex, buttonIndex))}
|
||||||
|
onCallbackClick={onCallbackClick}
|
||||||
|
onOpenMiniApp={onOpenMiniApp}
|
||||||
|
onOpenUrl={onOpenUrl}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineKeyboardButtonView({
|
||||||
|
button,
|
||||||
|
buttonKey,
|
||||||
|
fullWidth,
|
||||||
|
loading,
|
||||||
|
disabled,
|
||||||
|
onCallbackClick,
|
||||||
|
onOpenMiniApp,
|
||||||
|
onOpenUrl
|
||||||
|
}: {
|
||||||
|
button: InlineKeyboardButton;
|
||||||
|
buttonKey: string;
|
||||||
|
fullWidth: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
onCallbackClick?: (callbackData: string, buttonKey: string) => void | Promise<void>;
|
||||||
|
onOpenMiniApp?: (url: string) => void;
|
||||||
|
onOpenUrl?: (url: string) => void;
|
||||||
|
}) {
|
||||||
|
const webAppUrl = button.web_app?.url;
|
||||||
|
const callbackData = button.callback_data ?? button.callbackData;
|
||||||
|
|
||||||
|
async function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (disabled || loading) return;
|
||||||
|
|
||||||
|
if (webAppUrl) {
|
||||||
|
if (onOpenMiniApp) {
|
||||||
|
onOpenMiniApp(webAppUrl);
|
||||||
|
} else {
|
||||||
|
window.open(webAppUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button.url) {
|
||||||
|
if (onOpenUrl) {
|
||||||
|
onOpenUrl(button.url);
|
||||||
|
} else {
|
||||||
|
window.open(button.url, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callbackData && onCallbackClick) {
|
||||||
|
await onCallbackClick(callbackData, buttonKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || loading}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex min-h-9 items-center justify-center rounded-xl border border-[#dce3ec] bg-[#f4f7fb] px-3 py-2 text-xs font-medium text-[#1f2430] transition',
|
||||||
|
'hover:bg-[#e8eef6] disabled:cursor-not-allowed disabled:opacity-60',
|
||||||
|
fullWidth ? 'w-full' : 'min-w-0 flex-1'
|
||||||
|
)}
|
||||||
|
onClick={(event) => void handleClick(event)}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : button.text}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
apps/frontend/components/chat/bot-message-keyboard.tsx
Normal file
109
apps/frontend/components/chat/bot-message-keyboard.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { BotInlineKeyboard } from '@/components/chat/bot-inline-keyboard';
|
||||||
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
|
import { useRealtime } from '@/components/notifications/realtime-provider';
|
||||||
|
import { submitBotCallback } from '@/lib/api';
|
||||||
|
|
||||||
|
const CALLBACK_ANSWER_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
interface BotMessageKeyboardProps {
|
||||||
|
botRef: string;
|
||||||
|
messageId: number;
|
||||||
|
markup: unknown;
|
||||||
|
token: string | null;
|
||||||
|
botId?: string;
|
||||||
|
onOpenMiniApp?: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BotMessageKeyboard({
|
||||||
|
botRef,
|
||||||
|
messageId,
|
||||||
|
markup,
|
||||||
|
token,
|
||||||
|
botId,
|
||||||
|
onOpenMiniApp
|
||||||
|
}: BotMessageKeyboardProps) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { subscribe } = useRealtime();
|
||||||
|
const [loadingButtonKey, setLoadingButtonKey] = useState<string | null>(null);
|
||||||
|
const pendingQueriesRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
|
||||||
|
|
||||||
|
const clearPendingQuery = useCallback((callbackQueryId: string) => {
|
||||||
|
const timer = pendingQueriesRef.current.get(callbackQueryId);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
pendingQueriesRef.current.delete(callbackQueryId);
|
||||||
|
}
|
||||||
|
setLoadingButtonKey(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
pendingQueriesRef.current.forEach((timer) => clearTimeout(timer));
|
||||||
|
pendingQueriesRef.current.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribe((event) => {
|
||||||
|
if (event.type !== 'bot_callback_answer') return;
|
||||||
|
|
||||||
|
const payload = event.payload ?? {};
|
||||||
|
const callbackQueryId = typeof payload.callbackQueryId === 'string' ? payload.callbackQueryId : null;
|
||||||
|
if (!callbackQueryId || !pendingQueriesRef.current.has(callbackQueryId)) return;
|
||||||
|
|
||||||
|
const eventBotId = typeof payload.botId === 'string' ? payload.botId : null;
|
||||||
|
if (botId && eventBotId && eventBotId !== botId) return;
|
||||||
|
|
||||||
|
clearPendingQuery(callbackQueryId);
|
||||||
|
|
||||||
|
const text = typeof payload.text === 'string' ? payload.text.trim() : '';
|
||||||
|
const showAlert = Boolean(payload.showAlert);
|
||||||
|
const url = typeof payload.url === 'string' ? payload.url.trim() : '';
|
||||||
|
|
||||||
|
if (url && onOpenMiniApp) {
|
||||||
|
onOpenMiniApp(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
if (showAlert) {
|
||||||
|
window.alert(text);
|
||||||
|
} else {
|
||||||
|
showToast(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [botId, clearPendingQuery, onOpenMiniApp, showToast, subscribe]);
|
||||||
|
|
||||||
|
async function handleCallbackClick(callbackData: string, buttonKey: string) {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
setLoadingButtonKey(buttonKey);
|
||||||
|
try {
|
||||||
|
const response = await submitBotCallback(botRef, messageId, callbackData, token);
|
||||||
|
const callbackQueryId = response.callbackQueryId;
|
||||||
|
if (!callbackQueryId) {
|
||||||
|
setLoadingButtonKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => clearPendingQuery(callbackQueryId), CALLBACK_ANSWER_TIMEOUT_MS);
|
||||||
|
pendingQueriesRef.current.set(callbackQueryId, timer);
|
||||||
|
} catch (error) {
|
||||||
|
setLoadingButtonKey(null);
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось отправить действие');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BotInlineKeyboard
|
||||||
|
markup={markup}
|
||||||
|
messageId={messageId}
|
||||||
|
loadingButtonKey={loadingButtonKey}
|
||||||
|
onCallbackClick={handleCallbackClick}
|
||||||
|
onOpenMiniApp={onOpenMiniApp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
apps/frontend/components/chat/chat-delete-message-dialog.tsx
Normal file
46
apps/frontend/components/chat/chat-delete-message-dialog.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface ChatDeleteMessageDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
count: number;
|
||||||
|
loading?: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatDeleteMessageDialog({ open, count, loading, onOpenChange, onConfirm }: ChatDeleteMessageDialogProps) {
|
||||||
|
const plural =
|
||||||
|
count === 1 ? 'это сообщение' : count < 5 ? `${count} сообщения` : `${count} сообщений`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="rounded-[24px] sm:max-w-[420px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Удалить сообщение?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-[#667085]">
|
||||||
|
{count === 1
|
||||||
|
? 'Сообщение будет удалено для всех участников чата. Это действие нельзя отменить.'
|
||||||
|
: `Будут удалены ${plural}. Это действие нельзя отменить.`}
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" className="rounded-xl" disabled={loading} onClick={() => onOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl bg-red-600 text-white hover:bg-red-700"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => void onConfirm()}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Удалить'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/frontend/components/chat/chat-emoji-content.tsx
Normal file
28
apps/frontend/components/chat/chat-emoji-content.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { isEmojiId } from '@/lib/emoji-catalog';
|
||||||
|
import { emojiIdToNative, resolveNativeEmojiContent } from '@/lib/emoji-native-map';
|
||||||
|
|
||||||
|
export function ChatEmojiContent({ content, size = 22 }: { content?: string | null; size?: number }) {
|
||||||
|
if (!content) return null;
|
||||||
|
|
||||||
|
if (isEmojiId(content)) {
|
||||||
|
const native = emojiIdToNative(content);
|
||||||
|
if (native) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="text-[length:var(--emoji-size)] leading-none"
|
||||||
|
style={{ ['--emoji-size' as string]: `${size + 8}px` }}
|
||||||
|
>
|
||||||
|
{native}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/:([a-z0-9-]+):/.test(content)) {
|
||||||
|
return <span className="whitespace-pre-wrap break-words">{resolveNativeEmojiContent(content)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className="whitespace-pre-wrap break-words">{content}</span>;
|
||||||
|
}
|
||||||
69
apps/frontend/components/chat/chat-forward-dialog.tsx
Normal file
69
apps/frontend/components/chat/chat-forward-dialog.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
|
||||||
|
import type { ChatRoom } from '@/lib/api';
|
||||||
|
import { roomDisplayLabel } from '@/lib/family-chat';
|
||||||
|
|
||||||
|
interface ChatForwardDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
rooms: ChatRoom[];
|
||||||
|
viewerUserId: string;
|
||||||
|
token: string | null;
|
||||||
|
currentRoomId?: string;
|
||||||
|
forwarding?: boolean;
|
||||||
|
onSelectRoom: (roomId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatForwardDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
rooms,
|
||||||
|
viewerUserId,
|
||||||
|
token,
|
||||||
|
currentRoomId,
|
||||||
|
forwarding,
|
||||||
|
onSelectRoom
|
||||||
|
}: ChatForwardDialogProps) {
|
||||||
|
const targets = rooms.filter((room) => room.id !== currentRoomId && room.type !== 'BOT');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md rounded-[24px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Переслать в чат</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{forwarding ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-8 text-sm text-[#667085]">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Пересылка...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[360px] space-y-1 overflow-y-auto">
|
||||||
|
{targets.length ? (
|
||||||
|
targets.map((room) => (
|
||||||
|
<button
|
||||||
|
key={room.id}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-left transition hover:bg-[#f4f5f8]"
|
||||||
|
onClick={() => onSelectRoom(room.id)}
|
||||||
|
>
|
||||||
|
<ChatRoomAvatarDisplay room={room} viewerUserId={viewerUserId} token={token} size="sm" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{roomDisplayLabel(room, viewerUserId)}</p>
|
||||||
|
<p className="text-xs text-[#667085]">{room.members.length} участников</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="py-6 text-center text-sm text-[#667085]">Нет доступных чатов для пересылки</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
apps/frontend/components/chat/chat-message-context-menu.tsx
Normal file
173
apps/frontend/components/chat/chat-message-context-menu.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CheckCircle2, Copy, Forward, Pencil, Reply, Trash2 } from 'lucide-react';
|
||||||
|
import { ChatMessageReactions } from '@/components/chat/chat-message-reactions';
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuTrigger
|
||||||
|
} from '@/components/ui/context-menu';
|
||||||
|
import type { ChatMessage } from '@/lib/api';
|
||||||
|
import { QUICK_REACTIONS, canDeleteMessage, canEditMessage, getMessageCopyText } from '@/lib/chat-message-utils';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ChatMessageContextMenuProps {
|
||||||
|
message: ChatMessage;
|
||||||
|
userId?: string;
|
||||||
|
isE2E?: boolean;
|
||||||
|
visibleText?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
onReply?: () => void;
|
||||||
|
onForward?: () => void;
|
||||||
|
onSelect?: () => void;
|
||||||
|
onToggleReaction?: (emoji: string) => void;
|
||||||
|
onCopy?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessageContextMenu({
|
||||||
|
message,
|
||||||
|
userId,
|
||||||
|
isE2E,
|
||||||
|
visibleText,
|
||||||
|
children,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onReply,
|
||||||
|
onForward,
|
||||||
|
onSelect,
|
||||||
|
onToggleReaction,
|
||||||
|
onCopy
|
||||||
|
}: ChatMessageContextMenuProps) {
|
||||||
|
const editable = canEditMessage(message, userId, isE2E);
|
||||||
|
const deletable = canDeleteMessage(message, userId);
|
||||||
|
const copyText = getMessageCopyText(message, visibleText);
|
||||||
|
const disabled = message.isDeleted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="min-w-[240px]">
|
||||||
|
{!disabled ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-1 flex items-center gap-1 overflow-x-auto px-1 py-1">
|
||||||
|
{QUICK_REACTIONS.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-lg transition hover:bg-[#eef1f6]"
|
||||||
|
onClick={() => onToggleReaction?.(emoji)}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{onReply && !disabled ? (
|
||||||
|
<ContextMenuItem onClick={onReply}>
|
||||||
|
<Reply className="h-4 w-4" />
|
||||||
|
Ответить
|
||||||
|
</ContextMenuItem>
|
||||||
|
) : null}
|
||||||
|
{editable ? (
|
||||||
|
<ContextMenuItem onClick={onEdit}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
Изменить
|
||||||
|
</ContextMenuItem>
|
||||||
|
) : null}
|
||||||
|
{copyText ? (
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(copyText);
|
||||||
|
onCopy?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Копировать текст
|
||||||
|
</ContextMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onForward && !disabled && !message.isEncrypted ? (
|
||||||
|
<ContextMenuItem onClick={onForward}>
|
||||||
|
<Forward className="h-4 w-4" />
|
||||||
|
Переслать
|
||||||
|
</ContextMenuItem>
|
||||||
|
) : null}
|
||||||
|
{deletable ? (
|
||||||
|
<ContextMenuItem className="text-red-600 data-[highlighted]:bg-red-50 data-[highlighted]:text-red-700" onClick={onDelete}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Удалить
|
||||||
|
</ContextMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onSelect && !disabled ? (
|
||||||
|
<>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem onClick={onSelect}>
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
Выделить
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{!disabled && message.reactions?.length ? (
|
||||||
|
<div className="px-2 pb-1 pt-2">
|
||||||
|
<ChatMessageReactions reactions={message.reactions} onToggle={onToggleReaction} compact />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectableMessageWrapperProps {
|
||||||
|
selected?: boolean;
|
||||||
|
selectionMode?: boolean;
|
||||||
|
align?: 'left' | 'right';
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectableMessageWrapper({
|
||||||
|
selected,
|
||||||
|
selectionMode,
|
||||||
|
align = 'left',
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
children
|
||||||
|
}: SelectableMessageWrapperProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative w-fit max-w-[78%] shrink-0',
|
||||||
|
selectionMode && 'cursor-pointer',
|
||||||
|
selectionMode && (align === 'right' ? 'mr-7' : 'ml-7'),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={selectionMode ? onClick : undefined}
|
||||||
|
>
|
||||||
|
{selectionMode ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute top-1/2 z-10 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full border text-[10px] shadow-sm transition',
|
||||||
|
align === 'right' ? 'right-0 translate-x-[calc(100%+8px)]' : 'left-0 -translate-x-[calc(100%+8px)]',
|
||||||
|
selected ? 'border-[#3390ec] bg-[#3390ec] text-white' : 'border-[#dce3ec] bg-white text-transparent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-[18px] transition',
|
||||||
|
selected && 'ring-2 ring-[#3390ec] ring-offset-2 ring-offset-[#eef1f6]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/frontend/components/chat/chat-message-edit-banner.tsx
Normal file
26
apps/frontend/components/chat/chat-message-edit-banner.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Pencil, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface ChatMessageEditBannerProps {
|
||||||
|
preview?: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessageEditBanner({ preview, onCancel }: ChatMessageEditBannerProps) {
|
||||||
|
return (
|
||||||
|
<div className="mb-2 flex items-center gap-3 rounded-xl border border-[#3390ec]/20 bg-[#eef4ff] px-3 py-2 animate-in fade-in slide-in-from-bottom-1 duration-200">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#3390ec]/15 text-[#3390ec]">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-[#3390ec]">Редактирование сообщения</p>
|
||||||
|
{preview ? <p className="truncate text-xs text-[#667085]">{preview}</p> : null}
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 rounded-lg" aria-label="Отменить редактирование" onClick={onCancel}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
apps/frontend/components/chat/chat-message-reactions.tsx
Normal file
35
apps/frontend/components/chat/chat-message-reactions.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ChatMessageReaction } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ChatMessageReactionsProps {
|
||||||
|
reactions?: ChatMessageReaction[];
|
||||||
|
onToggle?: (emoji: string) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessageReactions({ reactions, onToggle, compact }: ChatMessageReactionsProps) {
|
||||||
|
if (!reactions?.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-wrap gap-1', compact ? 'mt-1' : 'mt-1.5')}>
|
||||||
|
{reactions.map((reaction) => (
|
||||||
|
<button
|
||||||
|
key={reaction.emoji}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle?.(reaction.emoji)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition',
|
||||||
|
reaction.reactedByMe
|
||||||
|
? 'border-[#3390ec]/40 bg-[#3390ec]/15 text-[#1f2430]'
|
||||||
|
: 'border-[#dce3ec] bg-white/80 text-[#667085] hover:bg-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{reaction.emoji}</span>
|
||||||
|
<span>{reaction.count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/frontend/components/chat/chat-selection-toolbar.tsx
Normal file
43
apps/frontend/components/chat/chat-selection-toolbar.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Copy, Forward, Loader2, Trash2, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface ChatSelectionToolbarProps {
|
||||||
|
count: number;
|
||||||
|
deleting?: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onCopy: () => void;
|
||||||
|
onForward: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatSelectionToolbar({ count, deleting, onCancel, onCopy, onForward, onDelete }: ChatSelectionToolbarProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 border-t border-[#dce3ec] bg-white px-4 py-3 shadow-[0_-8px_24px_rgba(31,36,48,0.08)] animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0 rounded-xl" onClick={onCancel}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<p className="min-w-0 flex-1 text-sm font-medium">{count} выбрано</p>
|
||||||
|
<Button type="button" variant="secondary" size="sm" className="rounded-xl" onClick={onCopy}>
|
||||||
|
<Copy className="mr-1 h-4 w-4" />
|
||||||
|
Копировать
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" size="sm" className="rounded-xl" onClick={onForward}>
|
||||||
|
<Forward className="mr-1 h-4 w-4" />
|
||||||
|
Переслать
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl text-red-600 hover:text-red-700"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
{deleting ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Trash2 className="mr-1 h-4 w-4" />}
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
apps/frontend/components/chat/emoji-picker.tsx
Normal file
84
apps/frontend/components/chat/emoji-picker.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
import { EMOJI_CATEGORIES, EMOJI_DEFINITIONS, searchEmojis, type EmojiCategoryId } from '@/lib/emoji-catalog';
|
||||||
|
|
||||||
|
import { emojiIdToNative } from '@/lib/emoji-native-map';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
interface EmojiPickerProps {
|
||||||
|
|
||||||
|
onSelect: (nativeEmoji: string) => void;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function EmojiPicker({ onSelect, className }: EmojiPickerProps) {
|
||||||
|
|
||||||
|
const [category, setCategory] = useState<EmojiCategoryId>('smileys');
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
|
||||||
|
const filtered = searchEmojis(query);
|
||||||
|
|
||||||
|
if (query.trim()) return filtered;
|
||||||
|
|
||||||
|
return filtered.filter((item) => item.category === category);
|
||||||
|
|
||||||
|
}, [category, query]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div className={cn('rounded-2xl border border-[#dce3ec] bg-white shadow-lg', className)}>
|
||||||
|
|
||||||
|
<div className="border-b border-[#eceef4] p-2">
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#a8adbc]" />
|
||||||
|
|
||||||
|
<Input
|
||||||
|
|
||||||
|
value={query}
|
||||||
|
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
|
||||||
|
placeholder="Поиск смайликов"
|
||||||
|
|
||||||
|
className="rounded-xl pl-9"
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!query.trim() ? (
|
||||||
|
|
||||||
|
<div className="flex gap-1 overflow-x-auto border-b border-[#eceef4] px-2 py-2">
|
||||||
|
|
||||||
|
{EMOJI_CATEGORIES.map((item) => {
|
||||||
|
|
||||||
|
const native = emojiIdToNative(item.icon) ?? '✨';
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
25
apps/frontend/components/chat/emoji-sprite-provider.tsx
Normal file
25
apps/frontend/components/chat/emoji-sprite-provider.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
let spriteInjected = false;
|
||||||
|
|
||||||
|
export function EmojiSpriteProvider() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (spriteInjected || typeof document === 'undefined') return;
|
||||||
|
fetch('/emojis/lendry-emojis.svg')
|
||||||
|
.then((response) => response.text())
|
||||||
|
.then((markup) => {
|
||||||
|
if (spriteInjected) return;
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.innerHTML = markup;
|
||||||
|
container.style.display = 'none';
|
||||||
|
container.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
spriteInjected = true;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
27
apps/frontend/components/chat/emoji-sprite.tsx
Normal file
27
apps/frontend/components/chat/emoji-sprite.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export const EMOJI_SPRITE_PATH = '/emojis/lendry-emojis.svg';
|
||||||
|
|
||||||
|
interface EmojiSpriteProps {
|
||||||
|
id: string;
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmojiSprite({ id, size = 24, className, title }: EmojiSpriteProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 36 36"
|
||||||
|
className={cn('inline-block shrink-0 align-middle', className)}
|
||||||
|
role="img"
|
||||||
|
aria-label={title}
|
||||||
|
>
|
||||||
|
<use href={`${EMOJI_SPRITE_PATH}#${id}`} xlinkHref={`${EMOJI_SPRITE_PATH}#${id}`} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
apps/frontend/components/chat/inline-editable-title.tsx
Normal file
105
apps/frontend/components/chat/inline-editable-title.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Loader2, Pencil } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface InlineEditableTitleProps {
|
||||||
|
value: string;
|
||||||
|
onSave: (nextValue: string) => Promise<void> | void;
|
||||||
|
className?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineEditableTitle({
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
className,
|
||||||
|
inputClassName,
|
||||||
|
disabled = false
|
||||||
|
}: InlineEditableTitleProps) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(value);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) setDraft(value);
|
||||||
|
}, [editing, value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (!trimmed || trimmed === value) {
|
||||||
|
setDraft(value);
|
||||||
|
setEditing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(trimmed);
|
||||||
|
setEditing(false);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<div className={cn('relative min-w-0', className)}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={draft}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onBlur={() => void commit()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
void commit();
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setDraft(value);
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full border-0 bg-transparent p-0 text-base font-semibold leading-tight text-[#1f2430] outline-none ring-0',
|
||||||
|
inputClassName
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{saving ? <Loader2 className="absolute -right-5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 animate-spin text-[#667085]" /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('group/title flex min-w-0 items-center gap-1.5', className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => !disabled && setEditing(true)}
|
||||||
|
className="min-w-0 truncate text-left text-base font-semibold leading-tight text-[#1f2430] transition hover:text-[#3390ec] disabled:cursor-default disabled:hover:text-[#1f2430]"
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</button>
|
||||||
|
{!disabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Изменить название"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-[#667085] opacity-0 transition hover:bg-[#f4f5f8] hover:text-[#3390ec] group-hover/title:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/frontend/components/chat/replied-message-preview.tsx
Normal file
45
apps/frontend/components/chat/replied-message-preview.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface RepliedMessagePreviewProps {
|
||||||
|
senderName: string;
|
||||||
|
preview: string;
|
||||||
|
accentClassName?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepliedMessagePreview({
|
||||||
|
senderName,
|
||||||
|
preview,
|
||||||
|
accentClassName = 'bg-[#3390ec]',
|
||||||
|
onClick,
|
||||||
|
className
|
||||||
|
}: RepliedMessagePreviewProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'mb-2 flex w-full items-stretch gap-2 rounded-xl bg-black/[0.04] px-2 py-1.5 text-left transition hover:bg-black/[0.07]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('w-0.5 shrink-0 rounded-full', accentClassName)} aria-hidden />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-xs font-semibold text-[#3390ec]">{senderName}</span>
|
||||||
|
<span className="block truncate text-xs text-[#667085]">{preview || 'Сообщение'}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scrollToChatMessage(messageId: string) {
|
||||||
|
const element = document.getElementById(`msg-${messageId}`);
|
||||||
|
if (!element) return false;
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
element.classList.add('blink-highlight');
|
||||||
|
window.setTimeout(() => element.classList.remove('blink-highlight'), 2000);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
48
apps/frontend/components/chat/typing-indicator.tsx
Normal file
48
apps/frontend/components/chat/typing-indicator.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TypingIndicatorProps {
|
||||||
|
roomType?: string;
|
||||||
|
typers: Array<{ userId: string; userName: string }>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGroupRoomType(type?: string) {
|
||||||
|
return type === 'GROUP' || type === 'GENERAL';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TypingIndicator({ roomType, typers, className }: TypingIndicatorProps) {
|
||||||
|
if (!typers.length) return null;
|
||||||
|
|
||||||
|
const group = isGroupRoomType(roomType);
|
||||||
|
let label = 'печатает';
|
||||||
|
|
||||||
|
if (group) {
|
||||||
|
if (typers.length === 1) {
|
||||||
|
label = `${typers[0]!.userName} печатает`;
|
||||||
|
} else if (typers.length === 2) {
|
||||||
|
label = `${typers[0]!.userName} и ${typers[1]!.userName} печатают`;
|
||||||
|
} else {
|
||||||
|
label = `${typers.length} участника печатают`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-4 py-1 text-xs text-[#667085] transition-all duration-300 animate-in fade-in slide-in-from-bottom-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className="inline-flex gap-0.5">
|
||||||
|
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#3390ec]/70 [animation-delay:0ms]" />
|
||||||
|
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#3390ec]/70 [animation-delay:120ms]" />
|
||||||
|
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#3390ec]/70 [animation-delay:240ms]" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>{group ? label : 'печатает…'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
apps/frontend/components/family/chat-room-avatar-display.tsx
Normal file
83
apps/frontend/components/family/chat-room-avatar-display.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Bot } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { UserAvatar } from '@/components/id/user-avatar';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { ChatRoom, apiFetch } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ChatRoomAvatarDisplayProps {
|
||||||
|
room: ChatRoom;
|
||||||
|
viewerUserId: string;
|
||||||
|
token: string | null;
|
||||||
|
className?: string;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sizeClass(size: 'sm' | 'md') {
|
||||||
|
return size === 'sm' ? 'h-9 w-9 text-xs' : 'h-11 w-11 text-sm';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatRoomAvatarDisplay({ room, viewerUserId, token, className, size = 'md' }: ChatRoomAvatarDisplayProps) {
|
||||||
|
const [roomAvatarUrl, setRoomAvatarUrl] = useState<string | null>(null);
|
||||||
|
const peerMember = useMemo(
|
||||||
|
() =>
|
||||||
|
room.type === 'DIRECT' || room.type === 'E2E' || room.type === 'BOT'
|
||||||
|
? room.members.find((member) => member.userId !== viewerUserId)
|
||||||
|
: undefined,
|
||||||
|
[room.members, room.type, viewerUserId]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !room.hasAvatar || room.type === 'DIRECT' || room.type === 'E2E' || room.type === 'BOT') {
|
||||||
|
setRoomAvatarUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiFetch<{ accessUrl: string }>(`/media/chat/${room.id}/avatar/url`, {}, token)
|
||||||
|
.then((response) => setRoomAvatarUrl(response.accessUrl))
|
||||||
|
.catch(() => setRoomAvatarUrl(null));
|
||||||
|
}, [room.hasAvatar, room.id, room.type, token]);
|
||||||
|
|
||||||
|
if (peerMember && (room.type === 'DIRECT' || room.type === 'E2E')) {
|
||||||
|
return (
|
||||||
|
<UserAvatar
|
||||||
|
userId={peerMember.userId}
|
||||||
|
displayName={peerMember.displayName}
|
||||||
|
hasAvatar={peerMember.hasAvatar}
|
||||||
|
token={token}
|
||||||
|
isVerified={peerMember.isVerified}
|
||||||
|
verificationIcon={peerMember.verificationIcon}
|
||||||
|
className={cn(sizeClass(size), className)}
|
||||||
|
badgeSize="xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room.type === 'BOT') {
|
||||||
|
return (
|
||||||
|
<Avatar className={cn(sizeClass(size), className)}>
|
||||||
|
<AvatarFallback className="bg-[#3390ec]/15 text-[#3390ec]">
|
||||||
|
<Bot className={size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'} />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room.hasAvatar && roomAvatarUrl) {
|
||||||
|
return (
|
||||||
|
<Avatar className={cn(sizeClass(size), className)}>
|
||||||
|
<AvatarImage src={roomAvatarUrl} alt={room.name} />
|
||||||
|
<AvatarFallback>{room.name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar className={cn(sizeClass(size), className)}>
|
||||||
|
<AvatarFallback className={room.type === 'GENERAL' ? 'bg-[#3390ec]/15 text-[#3390ec]' : undefined}>
|
||||||
|
{room.type === 'GENERAL' ? 'В' : room.name.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
309
apps/frontend/components/family/family-bot-chat.tsx
Normal file
309
apps/frontend/components/family/family-bot-chat.tsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { LayoutGrid, Loader2, Send, X } from 'lucide-react';
|
||||||
|
import { BotMessageKeyboard } from '@/components/chat/bot-message-keyboard';
|
||||||
|
import { BotChatMessageContextMenu } from '@/components/chat/bot-chat-message-context-menu';
|
||||||
|
import { useRealtime } from '@/components/notifications/realtime-provider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
BotChatMessage,
|
||||||
|
BotChatMeta,
|
||||||
|
fetchBotChatMessages,
|
||||||
|
sendBotMessage
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { extractMessageReplyMarkup, serializeInlineKeyboardMarkup } from '@/lib/bot-reply-markup';
|
||||||
|
import { resolveComposerMenuButton, type ComposerMenuButton } from '@/lib/bot-menu-button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
|
|
||||||
|
function formatMessageTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBotRef(botRef: string, payloadBotUsername?: unknown) {
|
||||||
|
if (typeof payloadBotUsername === 'string' && payloadBotUsername.trim()) {
|
||||||
|
return payloadBotUsername.trim();
|
||||||
|
}
|
||||||
|
return botRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markupJsonFromPayload(payload: Record<string, unknown>) {
|
||||||
|
const rows = extractMessageReplyMarkup({
|
||||||
|
replyMarkup: payload.replyMarkup,
|
||||||
|
telegramReplyMarkup: payload.telegramReplyMarkup,
|
||||||
|
reply_markup: payload.reply_markup
|
||||||
|
});
|
||||||
|
return serializeInlineKeyboardMarkup(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FamilyBotChatProps {
|
||||||
|
botRef: string;
|
||||||
|
botName: string;
|
||||||
|
token: string | null;
|
||||||
|
className?: string;
|
||||||
|
inputPlaceholder?: string;
|
||||||
|
onOpenMiniApp?: (url: string) => void;
|
||||||
|
onBotMetaChange?: (meta: BotChatMeta) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FamilyBotChat({
|
||||||
|
botRef,
|
||||||
|
botName,
|
||||||
|
token,
|
||||||
|
className,
|
||||||
|
inputPlaceholder = 'Сообщение боту',
|
||||||
|
onOpenMiniApp,
|
||||||
|
onBotMetaChange
|
||||||
|
}: FamilyBotChatProps) {
|
||||||
|
const [messages, setMessages] = useState<BotChatMessage[]>([]);
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [composerMenuButton, setComposerMenuButton] = useState<ComposerMenuButton | null>(null);
|
||||||
|
const [internalMiniAppUrl, setInternalMiniAppUrl] = useState<string | null>(null);
|
||||||
|
const [botMeta, setBotMeta] = useState<BotChatMeta | null>(null);
|
||||||
|
const { subscribe } = useRealtime();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const openMiniApp = useCallback(
|
||||||
|
(url: string) => {
|
||||||
|
if (onOpenMiniApp) {
|
||||||
|
onOpenMiniApp(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInternalMiniAppUrl(url);
|
||||||
|
},
|
||||||
|
[onOpenMiniApp]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadMessages = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetchBotChatMessages(botRef, token);
|
||||||
|
setMessages(response.messages ?? []);
|
||||||
|
const menuButton = resolveComposerMenuButton({
|
||||||
|
composerMenuButtonJson: response.composerMenuButtonJson,
|
||||||
|
composerWebAppUrl: response.composerWebAppUrl
|
||||||
|
});
|
||||||
|
setComposerMenuButton(menuButton);
|
||||||
|
const meta: BotChatMeta = {
|
||||||
|
botId: response.botId,
|
||||||
|
botOwnerId: response.botOwnerId,
|
||||||
|
botUsername: response.botUsername,
|
||||||
|
botDisplayName: response.botDisplayName,
|
||||||
|
composerWebAppUrl: menuButton?.web_app.url ?? (response.composerWebAppUrl?.trim() || null),
|
||||||
|
composerMenuButtonJson: response.composerMenuButtonJson ?? (menuButton ? JSON.stringify(menuButton) : null),
|
||||||
|
manageWebAppUrl: response.manageWebAppUrl
|
||||||
|
};
|
||||||
|
setBotMeta(meta);
|
||||||
|
onBotMetaChange?.(meta);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [botRef, onBotMetaChange, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadMessages();
|
||||||
|
}, [loadMessages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribe((event) => {
|
||||||
|
const payload = event.payload ?? {};
|
||||||
|
const eventBotRef = resolveBotRef(botRef, payload.botUsername);
|
||||||
|
if (eventBotRef !== botRef && eventBotRef.replace(/_bot$/i, '') !== botRef.replace(/_bot$/i, '')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'bot_message') {
|
||||||
|
const messageId = Number(payload.messageId);
|
||||||
|
if (!Number.isFinite(messageId)) {
|
||||||
|
void loadMessages();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyMarkupJson = markupJsonFromPayload(payload);
|
||||||
|
const outbound: BotChatMessage = {
|
||||||
|
id: `out-ws-${messageId}`,
|
||||||
|
direction: 'out',
|
||||||
|
text: typeof payload.text === 'string' ? payload.text : '',
|
||||||
|
messageType: typeof payload.messageType === 'string' ? payload.messageType : 'text',
|
||||||
|
messageId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
replyMarkupJson
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((current) => {
|
||||||
|
const withoutDuplicate = current.filter((item) => item.messageId !== messageId || item.direction !== 'out');
|
||||||
|
return [...withoutDuplicate, outbound].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'bot_menu_button_updated') {
|
||||||
|
const menuButton = resolveComposerMenuButton({
|
||||||
|
composerMenuButtonJson:
|
||||||
|
typeof payload.composerMenuButtonJson === 'string' ? payload.composerMenuButtonJson : null,
|
||||||
|
composerWebAppUrl: typeof payload.composerWebAppUrl === 'string' ? payload.composerWebAppUrl : null
|
||||||
|
});
|
||||||
|
setComposerMenuButton(menuButton);
|
||||||
|
setBotMeta((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
composerWebAppUrl: menuButton?.web_app.url ?? null,
|
||||||
|
composerMenuButtonJson: menuButton ? JSON.stringify(menuButton) : null
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'bot_message_edited') {
|
||||||
|
const messageId = Number(payload.messageId);
|
||||||
|
if (!Number.isFinite(messageId)) {
|
||||||
|
void loadMessages();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyMarkupJson = markupJsonFromPayload(payload);
|
||||||
|
setMessages((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.messageId === messageId && item.direction === 'out'
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
text: typeof payload.text === 'string' ? payload.text : item.text,
|
||||||
|
replyMarkupJson: replyMarkupJson ?? item.replyMarkupJson ?? null
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [botRef, loadMessages, subscribe]);
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (!token || !draft.trim() || sending) return;
|
||||||
|
const text = draft.trim();
|
||||||
|
setSending(true);
|
||||||
|
setDraft('');
|
||||||
|
setMessages((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `in-local-${Date.now()}`,
|
||||||
|
direction: 'in',
|
||||||
|
text,
|
||||||
|
messageType: 'text',
|
||||||
|
messageId: 0,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
await sendBotMessage(botRef, text, token);
|
||||||
|
await loadMessages();
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex min-h-0 flex-1 flex-col', className)}>
|
||||||
|
<div className="flex-1 space-y-2 overflow-y-auto px-4 py-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-8 text-sm text-[#667085]">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
) : messages.length ? (
|
||||||
|
messages.map((message) => {
|
||||||
|
const mine = message.direction === 'in';
|
||||||
|
const hasKeyboard = Boolean(message.replyMarkupJson?.trim());
|
||||||
|
return (
|
||||||
|
<div key={message.id} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
|
||||||
|
<BotChatMessageContextMenu text={message.text} onCopy={() => showToast('Текст скопирован')}>
|
||||||
|
<div className={cn('max-w-[78%] rounded-[18px] px-3 py-2 shadow-sm', mine ? 'rounded-br-md bg-[#effdde]' : 'rounded-bl-md bg-white')}>
|
||||||
|
{!mine ? <p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{botName}</p> : null}
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{message.text || '…'}</p>
|
||||||
|
{!mine && hasKeyboard && message.messageId > 0 ? (
|
||||||
|
<BotMessageKeyboard
|
||||||
|
botRef={botRef}
|
||||||
|
botId={botMeta?.botId}
|
||||||
|
messageId={message.messageId}
|
||||||
|
markup={message.replyMarkupJson}
|
||||||
|
token={token}
|
||||||
|
onOpenMiniApp={openMiniApp}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<p className="mt-1 text-[10px] text-[#a8adbc]">{formatMessageTime(message.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</BotChatMessageContextMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="py-8 text-center text-sm text-[#667085]">Напишите /help, чтобы начать</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 border-t border-[#dce3ec] bg-white px-4 py-3">
|
||||||
|
{composerMenuButton ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="h-10 shrink-0 rounded-full border border-[#3390ec]/20 bg-[#3390ec]/10 px-3 text-[#3390ec] transition hover:bg-[#3390ec]/15"
|
||||||
|
aria-label={composerMenuButton.text}
|
||||||
|
title={composerMenuButton.text}
|
||||||
|
onClick={() => openMiniApp(composerMenuButton.web_app.url)}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="mr-1.5 h-4 w-4" />
|
||||||
|
<span className="max-w-[96px] truncate text-xs font-medium">{composerMenuButton.text}</span>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Input
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
placeholder={inputPlaceholder}
|
||||||
|
className="rounded-xl"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button className="h-10 w-10 shrink-0 rounded-full p-0" disabled={sending || !draft.trim()} onClick={() => void handleSend()}>
|
||||||
|
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<BotMiniAppSheet
|
||||||
|
url={internalMiniAppUrl}
|
||||||
|
title={composerMenuButton?.text ?? 'Mini App'}
|
||||||
|
onClose={() => setInternalMiniAppUrl(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BotMiniAppSheetProps {
|
||||||
|
url: string | null;
|
||||||
|
title?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BotMiniAppSheet({ url, title = 'Mini App', onClose }: BotMiniAppSheetProps) {
|
||||||
|
if (!url) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[70] flex items-end justify-center bg-black/40 p-4 sm:items-center">
|
||||||
|
<div className="flex h-[min(88vh,720px)] w-full max-w-[520px] flex-col overflow-hidden rounded-[24px] bg-white shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-[#eceef4] px-4 py-3">
|
||||||
|
<p className="font-semibold">{title}</p>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" aria-label="Закрыть" onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<iframe src={url} title={title} className="min-h-0 flex-1 border-0" allow="clipboard-read; clipboard-write" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,34 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Bot, Loader2 } from 'lucide-react';
|
||||||
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
|
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { FamilyInviteCandidate, getApiErrorMessage, searchFamilyInviteUsers, sendFamilyInvite } from '@/lib/api';
|
import {
|
||||||
|
FamilyGroup,
|
||||||
|
FamilyInviteCandidate,
|
||||||
|
ManagedBot,
|
||||||
|
addBotFatherToFamily,
|
||||||
|
addBotToFamily,
|
||||||
|
fetchMyBots,
|
||||||
|
getApiErrorMessage,
|
||||||
|
searchFamilyInviteUsers,
|
||||||
|
sendFamilyInvite
|
||||||
|
} from '@/lib/api';
|
||||||
|
|
||||||
export function FamilyInviteDialog({
|
export function FamilyInviteDialog({
|
||||||
groupId,
|
groupId,
|
||||||
|
group,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onInvited
|
onInvited
|
||||||
}: {
|
}: {
|
||||||
groupId: string;
|
groupId: string;
|
||||||
|
group?: FamilyGroup | null;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onInvited?: () => void;
|
onInvited?: () => void;
|
||||||
@@ -28,14 +40,39 @@ export function FamilyInviteDialog({
|
|||||||
const [inviteSearching, setInviteSearching] = useState(false);
|
const [inviteSearching, setInviteSearching] = useState(false);
|
||||||
const [selectedInviteUser, setSelectedInviteUser] = useState<FamilyInviteCandidate | null>(null);
|
const [selectedInviteUser, setSelectedInviteUser] = useState<FamilyInviteCandidate | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [myBots, setMyBots] = useState<ManagedBot[]>([]);
|
||||||
|
const [myBotsLoading, setMyBotsLoading] = useState(false);
|
||||||
|
const [addingBotId, setAddingBotId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const familyBotUsernames = useMemo(
|
||||||
|
() => new Set((group?.members ?? []).map((member) => member.botUsername).filter(Boolean) as string[]),
|
||||||
|
[group?.members]
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableMyBots = useMemo(
|
||||||
|
() => myBots.filter((bot) => !familyBotUsernames.has(bot.username)),
|
||||||
|
[familyBotUsernames, myBots]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setInviteQuery('');
|
setInviteQuery('');
|
||||||
setInviteResults([]);
|
setInviteResults([]);
|
||||||
setSelectedInviteUser(null);
|
setSelectedInviteUser(null);
|
||||||
|
setMyBots([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!token) return;
|
||||||
|
setMyBotsLoading(true);
|
||||||
|
fetchMyBots(token)
|
||||||
|
.then((response) => setMyBots(response.bots ?? []))
|
||||||
|
.catch(() => setMyBots([]))
|
||||||
|
.finally(() => setMyBotsLoading(false));
|
||||||
|
}, [open, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
if (!token || inviteQuery.trim().length < 2) {
|
if (!token || inviteQuery.trim().length < 2) {
|
||||||
setInviteResults([]);
|
setInviteResults([]);
|
||||||
return;
|
return;
|
||||||
@@ -52,21 +89,71 @@ export function FamilyInviteDialog({
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [groupId, inviteQuery, open, token]);
|
}, [groupId, inviteQuery, open, token]);
|
||||||
|
|
||||||
|
async function addBotById(botId: string, displayName: string) {
|
||||||
|
if (!token) return;
|
||||||
|
setAddingBotId(botId);
|
||||||
|
try {
|
||||||
|
await addBotToFamily(groupId, botId, token);
|
||||||
|
showToast(`${displayName} добавлен в семью`);
|
||||||
|
onOpenChange(false);
|
||||||
|
onInvited?.();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(getApiErrorMessage(error, 'Не удалось добавить бота') ?? 'Ошибка');
|
||||||
|
} finally {
|
||||||
|
setAddingBotId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitInvite() {
|
async function submitInvite() {
|
||||||
if (!token || !selectedInviteUser) return;
|
if (!token || !selectedInviteUser) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token);
|
if (selectedInviteUser.isBot) {
|
||||||
showToast('Приглашение отправлено');
|
if (selectedInviteUser.botId) {
|
||||||
|
await addBotToFamily(groupId, selectedInviteUser.botId, token);
|
||||||
|
showToast(`${selectedInviteUser.displayName} добавлен в семью`);
|
||||||
|
} else {
|
||||||
|
await addBotFatherToFamily(groupId, token);
|
||||||
|
showToast('BotFather добавлен в семью');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token);
|
||||||
|
showToast('Приглашение отправлено');
|
||||||
|
}
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onInvited?.();
|
onInvited?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(getApiErrorMessage(error, 'Не удалось отправить приглашение') ?? 'Ошибка');
|
showToast(
|
||||||
|
getApiErrorMessage(
|
||||||
|
error,
|
||||||
|
selectedInviteUser.isBot ? 'Не удалось добавить бота' : 'Не удалось отправить приглашение'
|
||||||
|
) ?? 'Ошибка'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function botCandidateLabel(candidate: FamilyInviteCandidate) {
|
||||||
|
if (!candidate.isBot) {
|
||||||
|
return candidate.email ?? candidate.phone ?? candidate.username ?? 'Без контактов';
|
||||||
|
}
|
||||||
|
const handle = `@${candidate.botUsername ?? 'bot'}`;
|
||||||
|
if (candidate.ownerDisplayName) {
|
||||||
|
return `${handle} · владелец: ${candidate.ownerDisplayName}`;
|
||||||
|
}
|
||||||
|
if (candidate.botUsername === 'BotFather_bot') {
|
||||||
|
return `${handle} — создавайте ботов через чат`;
|
||||||
|
}
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitButtonLabel() {
|
||||||
|
if (!selectedInviteUser?.isBot) return 'Отправить приглашение';
|
||||||
|
if (selectedInviteUser.botId) return 'Добавить бота';
|
||||||
|
return 'Добавить BotFather';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="rounded-[28px] sm:max-w-[420px]">
|
<DialogContent className="rounded-[28px] sm:max-w-[420px]">
|
||||||
@@ -75,33 +162,70 @@ export function FamilyInviteDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{selectedInviteUser ? (
|
{selectedInviteUser ? (
|
||||||
<div className="rounded-2xl bg-[#f4f5f8] px-4 py-3">
|
<div className="rounded-2xl bg-[#f4f5f8] px-4 py-3">
|
||||||
<p className="font-medium">{selectedInviteUser.displayName}</p>
|
<p className="font-medium">
|
||||||
<p className="text-sm text-[#667085]">
|
{selectedInviteUser.displayName}
|
||||||
{selectedInviteUser.email ?? selectedInviteUser.phone ?? selectedInviteUser.username ?? 'Контакт не указан'}
|
{selectedInviteUser.isBot ? ' 🤖' : ''}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-sm text-[#667085]">{botCandidateLabel(selectedInviteUser)}</p>
|
||||||
<button type="button" className="mt-2 text-sm text-[#667085] underline" onClick={() => setSelectedInviteUser(null)}>
|
<button type="button" className="mt-2 text-sm text-[#667085] underline" onClick={() => setSelectedInviteUser(null)}>
|
||||||
Выбрать другого пользователя
|
Выбрать другого
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
placeholder="ФИО, почта, телефон или логин"
|
placeholder="ФИО, почта, телефон, логин или @бот"
|
||||||
value={inviteQuery}
|
value={inviteQuery}
|
||||||
onChange={(event) => setInviteQuery(event.target.value)}
|
onChange={(event) => setInviteQuery(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{inviteQuery.trim().length < 2 ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-[#667085]">Мои боты</p>
|
||||||
|
<div className="max-h-40 overflow-y-auto rounded-2xl border border-[#eceef4]">
|
||||||
|
{myBotsLoading ? (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 text-sm text-[#667085]">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Загрузка ботов...
|
||||||
|
</div>
|
||||||
|
) : availableMyBots.length ? (
|
||||||
|
availableMyBots.map((bot) => (
|
||||||
|
<button
|
||||||
|
key={bot.id}
|
||||||
|
type="button"
|
||||||
|
disabled={addingBotId === bot.id}
|
||||||
|
className="flex w-full items-center gap-3 border-b border-[#eceef4] px-4 py-3 text-left last:border-b-0 hover:bg-[#fafbfd] disabled:opacity-60"
|
||||||
|
onClick={() => void addBotById(bot.id, bot.name)}
|
||||||
|
>
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
|
||||||
|
{addingBotId === bot.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bot className="h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{bot.name}</p>
|
||||||
|
<p className="truncate text-sm text-[#667085]">@{bot.username}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="px-4 py-3 text-sm text-[#667085]">
|
||||||
|
{myBots.length ? 'Все ваши боты уже в семье' : 'У вас пока нет ботов. Создайте через BotFather.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-[#667085]">Или найдите пользователя / бота другого владельца через поиск выше</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="mt-2 max-h-56 overflow-y-auto rounded-2xl border border-[#eceef4]">
|
<div className="mt-2 max-h-56 overflow-y-auto rounded-2xl border border-[#eceef4]">
|
||||||
{inviteSearching ? (
|
{inviteSearching ? (
|
||||||
<div className="flex items-center gap-2 px-4 py-3 text-sm text-[#667085]">
|
<div className="flex items-center gap-2 px-4 py-3 text-sm text-[#667085]">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Ищем пользователей...
|
Ищем...
|
||||||
</div>
|
</div>
|
||||||
) : inviteQuery.trim().length < 2 ? (
|
) : inviteQuery.trim().length < 2 ? (
|
||||||
<p className="px-4 py-3 text-sm text-[#667085]">Введите минимум 2 символа для поиска</p>
|
<p className="px-4 py-3 text-sm text-[#667085]">Введите минимум 2 символа для поиска пользователей и ботов</p>
|
||||||
) : inviteResults.length ? (
|
) : inviteResults.length ? (
|
||||||
inviteResults.map((candidate) => (
|
inviteResults.map((candidate) => (
|
||||||
<button
|
<button
|
||||||
key={candidate.id}
|
key={`${candidate.id}-${candidate.botId ?? 'user'}`}
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-3 border-b border-[#eceef4] px-4 py-3 text-left last:border-b-0 hover:bg-[#fafbfd]"
|
className="flex w-full items-center gap-3 border-b border-[#eceef4] px-4 py-3 text-left last:border-b-0 hover:bg-[#fafbfd]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -109,31 +233,38 @@ export function FamilyInviteDialog({
|
|||||||
setInviteResults([]);
|
setInviteResults([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AvatarWithPresence
|
{candidate.isBot ? (
|
||||||
userId={candidate.id}
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
|
||||||
displayName={candidate.displayName}
|
<Bot className="h-4 w-4" />
|
||||||
hasAvatar={candidate.hasAvatar}
|
</div>
|
||||||
token={token}
|
) : (
|
||||||
isVerified={candidate.isVerified}
|
<AvatarWithPresence
|
||||||
verificationIcon={candidate.verificationIcon}
|
userId={candidate.id}
|
||||||
className="h-9 w-9"
|
displayName={candidate.displayName}
|
||||||
/>
|
hasAvatar={candidate.hasAvatar}
|
||||||
|
token={token}
|
||||||
|
isVerified={candidate.isVerified}
|
||||||
|
verificationIcon={candidate.verificationIcon}
|
||||||
|
className="h-9 w-9"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium">{candidate.displayName}</p>
|
<p className="truncate font-medium">
|
||||||
<p className="truncate text-sm text-[#667085]">
|
{candidate.displayName}
|
||||||
{candidate.email ?? candidate.phone ?? candidate.username ?? 'Без контактов'}
|
{candidate.isBot ? ' 🤖' : ''}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="truncate text-sm text-[#667085]">{botCandidateLabel(candidate)}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="px-4 py-3 text-sm text-[#667085]">Пользователи не найдены</p>
|
<p className="px-4 py-3 text-sm text-[#667085]">Ничего не найдено</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button className="mt-4 w-full rounded-xl" disabled={!selectedInviteUser || submitting} onClick={() => void submitInvite()}>
|
<Button className="mt-4 w-full rounded-xl" disabled={!selectedInviteUser || submitting} onClick={() => void submitInvite()}>
|
||||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Отправить приглашение'}
|
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : submitButtonLabel()}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ const SELECTED_FAMILY_STORAGE_KEY = 'id-selected-family-group-id';
|
|||||||
type FamilyOverlayContextValue = {
|
type FamilyOverlayContextValue = {
|
||||||
openMiniChat: () => void;
|
openMiniChat: () => void;
|
||||||
openChatRoom: (roomId: string) => void;
|
openChatRoom: (roomId: string) => void;
|
||||||
openChatWithMember: (memberUserId: string, memberName: string) => void;
|
openChatWithMember: (memberUserId: string, memberName: string, options?: { isBot?: boolean; botUsername?: string }) => void;
|
||||||
closeMiniChat: () => void;
|
closeMiniChat: () => void;
|
||||||
miniChatOpen: boolean;
|
miniChatOpen: boolean;
|
||||||
pendingRoomId: string | null;
|
pendingRoomId: string | null;
|
||||||
pendingMember: { userId: string; name: string } | null;
|
pendingMember: { userId: string; name: string; isBot?: boolean; botUsername?: string } | null;
|
||||||
clearPending: () => void;
|
clearPending: () => void;
|
||||||
selectedGroupId: string | null;
|
selectedGroupId: string | null;
|
||||||
setSelectedGroupId: (groupId: string | null) => void;
|
setSelectedGroupId: (groupId: string | null) => void;
|
||||||
@@ -22,9 +22,9 @@ const FamilyOverlayContext = createContext<FamilyOverlayContextValue | null>(nul
|
|||||||
export function FamilyOverlayProvider({ children }: { children: React.ReactNode }) {
|
export function FamilyOverlayProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [miniChatOpen, setMiniChatOpen] = useState(false);
|
const [miniChatOpen, setMiniChatOpen] = useState(false);
|
||||||
const [pendingRoomId, setPendingRoomId] = useState<string | null>(null);
|
const [pendingRoomId, setPendingRoomId] = useState<string | null>(null);
|
||||||
const [pendingMember, setPendingMember] = useState<{ userId: string; name: string } | null>(null);
|
const [pendingMember, setPendingMember] = useState<{ userId: string; name: string; isBot?: boolean; botUsername?: string } | null>(null);
|
||||||
const [selectedGroupId, setSelectedGroupIdState] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupIdState] = useState<string | null>(null);
|
||||||
const pendingMemberRef = useRef<{ userId: string; name: string } | null>(null);
|
const pendingMemberRef = useRef<{ userId: string; name: string; isBot?: boolean; botUsername?: string } | null>(null);
|
||||||
const selectionHydratedRef = useRef(false);
|
const selectionHydratedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -58,13 +58,16 @@ export function FamilyOverlayProvider({ children }: { children: React.ReactNode
|
|||||||
setMiniChatOpen(true);
|
setMiniChatOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openChatWithMember = useCallback((memberUserId: string, memberName: string) => {
|
const openChatWithMember = useCallback(
|
||||||
const payload = { userId: memberUserId, name: memberName };
|
(memberUserId: string, memberName: string, options?: { isBot?: boolean; botUsername?: string }) => {
|
||||||
setPendingMember(payload);
|
const payload = { userId: memberUserId, name: memberName, ...options };
|
||||||
pendingMemberRef.current = payload;
|
setPendingMember(payload);
|
||||||
setPendingRoomId(null);
|
pendingMemberRef.current = payload;
|
||||||
setMiniChatOpen(true);
|
setPendingRoomId(null);
|
||||||
}, []);
|
setMiniChatOpen(true);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const clearPending = useCallback(() => {
|
const clearPending = useCallback(() => {
|
||||||
setPendingRoomId(null);
|
setPendingRoomId(null);
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Loader2, Search, UsersRound } from 'lucide-react';
|
import { Loader2, Plus, Search, UsersRound } from 'lucide-react';
|
||||||
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
|
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
|
||||||
import { FamilyGroupSelector } from '@/components/family/family-group-selector';
|
import { FamilyGroupSelector } from '@/components/family/family-group-selector';
|
||||||
import { FamilyInviteDialog } from '@/components/family/family-invite-dialog';
|
import { FamilyInviteDialog } from '@/components/family/family-invite-dialog';
|
||||||
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
|
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { useSelectedFamily } from '@/hooks/use-primary-family';
|
import { useSelectedFamily } from '@/hooks/use-primary-family';
|
||||||
|
import { addBotFatherToFamily, getApiErrorMessage } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export function FamilySidebarPanel() {
|
export function FamilySidebarPanel() {
|
||||||
@@ -25,7 +27,9 @@ export function FamilySidebarPanel() {
|
|||||||
refresh
|
refresh
|
||||||
} = useSelectedFamily(Boolean(user && token));
|
} = useSelectedFamily(Boolean(user && token));
|
||||||
const { openChatWithMember } = useFamilyOverlay();
|
const { openChatWithMember } = useFamilyOverlay();
|
||||||
|
const { showToast } = useToast();
|
||||||
const [inviteOpen, setInviteOpen] = useState(false);
|
const [inviteOpen, setInviteOpen] = useState(false);
|
||||||
|
const [addingBotFather, setAddingBotFather] = useState(false);
|
||||||
|
|
||||||
if (!user || !token) return null;
|
if (!user || !token) return null;
|
||||||
|
|
||||||
@@ -81,7 +85,12 @@ export function FamilySidebarPanel() {
|
|||||||
'flex w-full items-center gap-2 rounded-xl px-2 py-1.5 text-left transition hover:bg-[#f4f5f8]',
|
'flex w-full items-center gap-2 rounded-xl px-2 py-1.5 text-left transition hover:bg-[#f4f5f8]',
|
||||||
isSelf && 'bg-[#fafbfd]'
|
isSelf && 'bg-[#fafbfd]'
|
||||||
)}
|
)}
|
||||||
onClick={() => openChatWithMember(member.userId, member.displayName)}
|
onClick={() =>
|
||||||
|
openChatWithMember(member.userId, member.displayName, {
|
||||||
|
isBot: member.isBot,
|
||||||
|
botUsername: member.botUsername
|
||||||
|
})
|
||||||
|
}
|
||||||
title={isSelf ? 'Вы' : `Написать ${member.displayName}`}
|
title={isSelf ? 'Вы' : `Написать ${member.displayName}`}
|
||||||
>
|
>
|
||||||
<AvatarWithPresence
|
<AvatarWithPresence
|
||||||
@@ -97,17 +106,44 @@ export function FamilySidebarPanel() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-[12px] font-medium leading-tight">
|
<p className="truncate text-[12px] font-medium leading-tight">
|
||||||
{member.displayName}
|
{member.displayName}
|
||||||
|
{member.isBot ? ' 🤖' : ''}
|
||||||
{isSelf ? ' (Вы)' : ''}
|
{isSelf ? ' (Вы)' : ''}
|
||||||
</p>
|
</p>
|
||||||
{!isSelf ? (
|
{!isSelf ? (
|
||||||
<p className={cn('truncate text-[10px]', presence?.online ? 'text-emerald-600' : 'text-[#a8adbc]')}>
|
<p className={cn('truncate text-[10px]', member.isBot ? 'text-[#3390ec]' : presence?.online ? 'text-emerald-600' : 'text-[#a8adbc]')}>
|
||||||
{presence?.online ? 'В сети' : 'Не в сети'}
|
{member.isBot ? 'Бот' : presence?.online ? 'В сети' : 'Не в сети'}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{group?.botFatherAvailable ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={addingBotFather}
|
||||||
|
className="flex w-full items-center gap-2 rounded-xl border border-dashed border-[#d5dbe8] px-2 py-2 text-left transition hover:bg-[#f4f5f8] disabled:opacity-60"
|
||||||
|
onClick={() => {
|
||||||
|
if (!group || !token) return;
|
||||||
|
setAddingBotFather(true);
|
||||||
|
void addBotFatherToFamily(group.id, token)
|
||||||
|
.then(() => {
|
||||||
|
showToast('BotFather добавлен в семью');
|
||||||
|
void refresh();
|
||||||
|
})
|
||||||
|
.catch((error) => showToast(getApiErrorMessage(error, 'Не удалось добавить BotFather') ?? 'Ошибка'))
|
||||||
|
.finally(() => setAddingBotFather(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
|
||||||
|
{addingBotFather ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-[12px] font-medium leading-tight">Добавить BotFather 🤖</p>
|
||||||
|
<p className="truncate text-[10px] text-[#667085]">Создавайте ботов через чат</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl bg-[#f4f5f8] px-2 py-3 text-[11px] text-[#667085]">
|
<div className="rounded-xl bg-[#f4f5f8] px-2 py-3 text-[11px] text-[#667085]">
|
||||||
@@ -120,6 +156,7 @@ export function FamilySidebarPanel() {
|
|||||||
{hasFamily ? (
|
{hasFamily ? (
|
||||||
<FamilyInviteDialog
|
<FamilyInviteDialog
|
||||||
groupId={group!.id}
|
groupId={group!.id}
|
||||||
|
group={group}
|
||||||
open={inviteOpen}
|
open={inviteOpen}
|
||||||
onOpenChange={setInviteOpen}
|
onOpenChange={setInviteOpen}
|
||||||
onInvited={() => void refresh()}
|
onInvited={() => void refresh()}
|
||||||
|
|||||||
62
apps/frontend/components/family/hover-upload-avatar.tsx
Normal file
62
apps/frontend/components/family/hover-upload-avatar.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Camera, Loader2 } from 'lucide-react';
|
||||||
|
import { useId } from 'react';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface HoverUploadAvatarProps {
|
||||||
|
name: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
uploading?: boolean;
|
||||||
|
onFileSelect: (file: File) => void;
|
||||||
|
className?: string;
|
||||||
|
fallbackClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HoverUploadAvatar({
|
||||||
|
name,
|
||||||
|
imageUrl,
|
||||||
|
uploading = false,
|
||||||
|
onFileSelect,
|
||||||
|
className,
|
||||||
|
fallbackClassName
|
||||||
|
}: HoverUploadAvatarProps) {
|
||||||
|
const inputId = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className={cn(
|
||||||
|
'group/avatar relative block cursor-pointer overflow-hidden rounded-full transition',
|
||||||
|
uploading && 'pointer-events-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar className="h-full w-full">
|
||||||
|
{imageUrl ? <AvatarImage src={imageUrl} alt={name} /> : null}
|
||||||
|
<AvatarFallback className={fallbackClassName}>{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 flex items-center justify-center bg-black/45 text-white opacity-0 transition-opacity duration-200 group-hover/avatar:opacity-100',
|
||||||
|
uploading && 'opacity-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{uploading ? <Loader2 className="h-5 w-5 animate-spin" /> : <Camera className="h-5 w-5" />}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="sr-only"
|
||||||
|
disabled={uploading}
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) onFileSelect(file);
|
||||||
|
event.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Boxes, Settings2, ShieldCheck, Users } from 'lucide-react';
|
import { Bot, Boxes, Settings2, ShieldCheck, Users } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { PublicUser } from '@/lib/api';
|
import { PublicUser } from '@/lib/api';
|
||||||
|
|
||||||
@@ -12,6 +12,12 @@ const links = [
|
|||||||
icon: Users,
|
icon: Users,
|
||||||
permissions: ['canViewUsers', 'canManageUsers'] as const
|
permissions: ['canViewUsers', 'canManageUsers'] as const
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/admin/bots',
|
||||||
|
label: 'Боты',
|
||||||
|
icon: Bot,
|
||||||
|
permissions: ['canManageBots'] as const
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/admin/oauth',
|
href: '/admin/oauth',
|
||||||
label: 'OAuth',
|
label: 'OAuth',
|
||||||
|
|||||||
@@ -100,10 +100,10 @@ export function OAuthClientDetailDialog({
|
|||||||
Быстрый старт
|
Быстрый старт
|
||||||
</div>
|
</div>
|
||||||
<ol className="list-decimal space-y-1 pl-5 text-xs text-[#667085]">
|
<ol className="list-decimal space-y-1 pl-5 text-xs text-[#667085]">
|
||||||
<li>Авторизуйте пользователя в IdP и получите userId.</li>
|
<li>Укажите issuer = PUBLIC_API_URL в OIDC-клиенте (PHP, Keycloak, Grafana и т.д.).</li>
|
||||||
<li>Перенаправьте на authorization endpoint с clientId, redirectUri, scope, userId.</li>
|
<li>Перенаправьте пользователя на authorization endpoint — стандартные параметры client_id, redirect_uri, response_type=code.</li>
|
||||||
<li>На backend обменяйте code через POST /oauth/token с client_secret.</li>
|
<li>IdP покажет вход и экран «Разрешить доступ», затем вернёт code на redirect_uri.</li>
|
||||||
<li>Запросите профиль через GET /oauth/userinfo с access token.</li>
|
<li>На backend обменяйте code через POST /oauth/token (form-urlencoded или JSON).</li>
|
||||||
</ol>
|
</ol>
|
||||||
<a
|
<a
|
||||||
href={endpoints.openIdConfigurationUrl}
|
href={endpoints.openIdConfigurationUrl}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
import { Sidebar } from './sidebar';
|
import { Sidebar } from './sidebar';
|
||||||
import { UserMenu } from './user-menu';
|
import { UserMenu } from './user-menu';
|
||||||
import { NotificationBell } from '@/components/notifications/notification-bell';
|
import { NotificationBell } from '@/components/notifications/notification-bell';
|
||||||
|
import { EmojiSpriteProvider } from '@/components/chat/emoji-sprite-provider';
|
||||||
import { FamilyOverlayProvider } from '@/components/family/family-overlay-provider';
|
import { FamilyOverlayProvider } from '@/components/family/family-overlay-provider';
|
||||||
import { MiniFamilyChat } from '@/components/family/mini-family-chat';
|
import { MiniFamilyChat } from '@/components/family/mini-family-chat';
|
||||||
|
|
||||||
export function IdShell({ active, children, wide = false }: { active: string; children: React.ReactNode; wide?: boolean }) {
|
export function IdShell({ active, children, wide = false }: { active: string; children: React.ReactNode; wide?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<FamilyOverlayProvider>
|
<FamilyOverlayProvider>
|
||||||
|
<EmojiSpriteProvider />
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
<Sidebar active={active} />
|
<Sidebar active={active} />
|
||||||
<div className="fixed right-4 top-4 z-40 flex items-center gap-2 lg:right-8 lg:top-6">
|
<div className="fixed right-4 top-4 z-40 flex items-center gap-2 lg:right-8 lg:top-6">
|
||||||
|
|||||||
39
apps/frontend/components/ui/context-menu.tsx
Normal file
39
apps/frontend/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export const ContextMenu = ContextMenuPrimitive.Root;
|
||||||
|
export const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||||
|
export const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||||
|
|
||||||
|
export function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[220px] overflow-hidden rounded-2xl border border-[#dce3ec] bg-white p-1.5 text-[#1f2430] shadow-[0_12px_40px_rgba(31,36,48,0.12)] animate-in fade-in zoom-in-95 duration-150',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextMenuItem({ className, inset, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & { inset?: boolean }) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer select-none items-center gap-3 rounded-xl px-3 py-2.5 text-sm outline-none data-[highlighted]:bg-[#f4f5f8] data-[highlighted]:text-[#1f2430]',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||||
|
return <ContextMenuPrimitive.Separator className={cn('my-1 h-px bg-[#eceef4]', className)} {...props} />;
|
||||||
|
}
|
||||||
57
apps/frontend/hooks/use-chat-drag-selection.ts
Normal file
57
apps/frontend/hooks/use-chat-drag-selection.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface UseChatDragSelectionOptions {
|
||||||
|
selectionMode: boolean;
|
||||||
|
enterSelectionMode: (messageId: string) => void;
|
||||||
|
addSelectedMessage: (messageId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatDragSelection({
|
||||||
|
selectionMode,
|
||||||
|
enterSelectionMode,
|
||||||
|
addSelectedMessage
|
||||||
|
}: UseChatDragSelectionOptions) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const visitedRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const startDragSelection = useCallback(
|
||||||
|
(messageId: string) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
visitedRef.current = new Set([messageId]);
|
||||||
|
if (!selectionMode) {
|
||||||
|
enterSelectionMode(messageId);
|
||||||
|
} else {
|
||||||
|
addSelectedMessage(messageId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addSelectedMessage, enterSelectionMode, selectionMode]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnterMessage = useCallback(
|
||||||
|
(messageId: string) => {
|
||||||
|
if (!isDragging || visitedRef.current.has(messageId)) return;
|
||||||
|
visitedRef.current.add(messageId);
|
||||||
|
addSelectedMessage(messageId);
|
||||||
|
},
|
||||||
|
[addSelectedMessage, isDragging]
|
||||||
|
);
|
||||||
|
|
||||||
|
const endDragSelection = useCallback(() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
visitedRef.current.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('mouseup', endDragSelection);
|
||||||
|
return () => window.removeEventListener('mouseup', endDragSelection);
|
||||||
|
}, [endDragSelection]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDragging,
|
||||||
|
startDragSelection,
|
||||||
|
handleDragEnterMessage,
|
||||||
|
endDragSelection
|
||||||
|
};
|
||||||
|
}
|
||||||
106
apps/frontend/hooks/use-chat-message-selection.ts
Normal file
106
apps/frontend/hooks/use-chat-message-selection.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { ChatMessage } from '@/lib/api';
|
||||||
|
import { isSelectableMessage } from '@/lib/chat-message-utils';
|
||||||
|
|
||||||
|
export function useChatMessageSelection(messages: ChatMessage[]) {
|
||||||
|
const [selectionMode, setSelectionMode] = useState(false);
|
||||||
|
const [selectedMessageIds, setSelectedMessageIds] = useState<string[]>([]);
|
||||||
|
const anchorIndexRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const exitSelectionMode = useCallback(() => {
|
||||||
|
setSelectionMode(false);
|
||||||
|
setSelectedMessageIds([]);
|
||||||
|
anchorIndexRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectionMode) return;
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
exitSelectionMode();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [exitSelectionMode, selectionMode]);
|
||||||
|
|
||||||
|
const enterSelectionMode = useCallback((messageId?: string) => {
|
||||||
|
if (messageId) {
|
||||||
|
const message = messages.find((item) => item.id === messageId);
|
||||||
|
if (!message || !isSelectableMessage(message)) return;
|
||||||
|
}
|
||||||
|
setSelectionMode(true);
|
||||||
|
setSelectedMessageIds(messageId ? [messageId] : []);
|
||||||
|
anchorIndexRef.current = messageId ? messages.findIndex((item) => item.id === messageId) : null;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const addSelectedMessage = useCallback((messageId: string) => {
|
||||||
|
const message = messages.find((item) => item.id === messageId);
|
||||||
|
if (!message || !isSelectableMessage(message)) return;
|
||||||
|
setSelectionMode(true);
|
||||||
|
setSelectedMessageIds((current) => (current.includes(messageId) ? current : [...current, messageId]));
|
||||||
|
anchorIndexRef.current = messages.findIndex((item) => item.id === messageId);
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const toggleSelectedMessage = useCallback((messageId: string) => {
|
||||||
|
setSelectedMessageIds((current) => {
|
||||||
|
const next = current.includes(messageId) ? current.filter((id) => id !== messageId) : [...current, messageId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
anchorIndexRef.current = messages.findIndex((item) => item.id === messageId);
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleSelectionPointer = useCallback(
|
||||||
|
(messageId: string, event: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) => {
|
||||||
|
const index = messages.findIndex((item) => item.id === messageId);
|
||||||
|
if (index < 0) return false;
|
||||||
|
|
||||||
|
const multiKey = Boolean(event.ctrlKey || event.metaKey);
|
||||||
|
if (multiKey) {
|
||||||
|
if (!selectionMode) {
|
||||||
|
enterSelectionMode(messageId);
|
||||||
|
} else {
|
||||||
|
toggleSelectedMessage(messageId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.shiftKey) {
|
||||||
|
if (!selectionMode) {
|
||||||
|
enterSelectionMode(messageId);
|
||||||
|
}
|
||||||
|
const anchor = anchorIndexRef.current ?? index;
|
||||||
|
const start = Math.min(anchor, index);
|
||||||
|
const end = Math.max(anchor, index);
|
||||||
|
const rangeIds = messages
|
||||||
|
.slice(start, end + 1)
|
||||||
|
.filter((item) => isSelectableMessage(item))
|
||||||
|
.map((item) => item.id);
|
||||||
|
setSelectedMessageIds(rangeIds);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionMode) {
|
||||||
|
toggleSelectedMessage(messageId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[enterSelectionMode, messages, selectionMode, toggleSelectedMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectionMode,
|
||||||
|
selectedMessageIds,
|
||||||
|
setSelectedMessageIds,
|
||||||
|
enterSelectionMode,
|
||||||
|
exitSelectionMode,
|
||||||
|
toggleSelectedMessage,
|
||||||
|
addSelectedMessage,
|
||||||
|
handleSelectionPointer
|
||||||
|
};
|
||||||
|
}
|
||||||
44
apps/frontend/hooks/use-chat-typing.ts
Normal file
44
apps/frontend/hooks/use-chat-typing.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
type Typer = { userId: string; userName: string; expiresAt: number };
|
||||||
|
|
||||||
|
export function useChatTyping(currentUserId?: string) {
|
||||||
|
const [typers, setTypers] = useState<Typer[]>([]);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const pruneTypers = useCallback(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
setTypers((current) => current.filter((item) => item.expiresAt > now));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const registerTyper = useCallback(
|
||||||
|
(userId: string, userName: string) => {
|
||||||
|
if (!userId || userId === currentUserId) return;
|
||||||
|
const expiresAt = Date.now() + 3000;
|
||||||
|
setTypers((current) => {
|
||||||
|
const without = current.filter((item) => item.userId !== userId);
|
||||||
|
return [...without, { userId, userName, expiresAt }];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[currentUserId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearTypers = useCallback(() => {
|
||||||
|
setTypers([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
timerRef.current = window.setInterval(pruneTypers, 500);
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [pruneTypers]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
typers: typers.map(({ userId, userName }) => ({ userId, userName })),
|
||||||
|
registerTyper,
|
||||||
|
clearTypers
|
||||||
|
};
|
||||||
|
}
|
||||||
145
apps/frontend/hooks/use-e2e-chat.ts
Normal file
145
apps/frontend/hooks/use-e2e-chat.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ChatMessage, ChatRoom, fetchUserE2EPublicKey, setUserE2EPublicKey } from '@/lib/api';
|
||||||
|
import { ensureE2EKeyPair } from '@/lib/e2e-crypto';
|
||||||
|
import {
|
||||||
|
decryptE2EPayload,
|
||||||
|
encryptE2EPayload,
|
||||||
|
readableE2EPayload,
|
||||||
|
type E2EMessagePayload
|
||||||
|
} from '@/lib/e2e-chat';
|
||||||
|
|
||||||
|
export function resolveChatPeerUserId(room: ChatRoom | null | undefined, userId: string | undefined) {
|
||||||
|
if (!room || !userId) return null;
|
||||||
|
const fromMembers = room.members.find((member) => member.userId !== userId)?.userId;
|
||||||
|
if (fromMembers) return fromMembers;
|
||||||
|
if (room.peerUserId && room.peerUserId !== userId) return room.peerUserId;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getE2EMessageText(options: {
|
||||||
|
message: ChatMessage;
|
||||||
|
isE2E: boolean;
|
||||||
|
peerE2eKey: string | null;
|
||||||
|
peerKeyLoading: boolean;
|
||||||
|
e2ePayload?: E2EMessagePayload | null;
|
||||||
|
e2eError?: string;
|
||||||
|
}) {
|
||||||
|
const { message, isE2E, peerE2eKey, peerKeyLoading, e2ePayload, e2eError } = options;
|
||||||
|
if (!message.isEncrypted) {
|
||||||
|
return message.content ?? '';
|
||||||
|
}
|
||||||
|
if (e2ePayload) {
|
||||||
|
return readableE2EPayload(e2ePayload);
|
||||||
|
}
|
||||||
|
if (e2eError) {
|
||||||
|
return e2eError;
|
||||||
|
}
|
||||||
|
if (isE2E && peerKeyLoading) {
|
||||||
|
return '🔒 Расшифровка...';
|
||||||
|
}
|
||||||
|
if (isE2E && !peerE2eKey) {
|
||||||
|
return '🔒 Собеседник не опубликовал ключ шифрования';
|
||||||
|
}
|
||||||
|
return '🔒 Расшифровка...';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useE2EChat(options: {
|
||||||
|
room: ChatRoom | null | undefined;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
userId?: string;
|
||||||
|
token?: string | null;
|
||||||
|
}) {
|
||||||
|
const isE2E = options.room?.type === 'E2E';
|
||||||
|
const peerUserId = useMemo(
|
||||||
|
() => resolveChatPeerUserId(options.room, options.userId),
|
||||||
|
[options.room, options.userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [peerE2eKey, setPeerE2eKey] = useState<string | null>(null);
|
||||||
|
const [peerKeyLoading, setPeerKeyLoading] = useState(false);
|
||||||
|
const [e2ePayloads, setE2ePayloads] = useState<Record<string, E2EMessagePayload | null>>({});
|
||||||
|
const [e2eErrors, setE2eErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!options.userId || !options.token) return;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const { publicKey } = await ensureE2EKeyPair(options.userId!);
|
||||||
|
await setUserE2EPublicKey(options.userId!, publicKey, options.token);
|
||||||
|
} catch {
|
||||||
|
// фоновая инициализация E2E
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [options.token, options.userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isE2E || !peerUserId || !options.token) {
|
||||||
|
setPeerE2eKey(null);
|
||||||
|
setPeerKeyLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPeerKeyLoading(true);
|
||||||
|
void fetchUserE2EPublicKey(peerUserId, options.token)
|
||||||
|
.then((response) => setPeerE2eKey(response.e2ePublicKey ?? null))
|
||||||
|
.catch(() => setPeerE2eKey(null))
|
||||||
|
.finally(() => setPeerKeyLoading(false));
|
||||||
|
}, [isE2E, options.token, peerUserId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isE2E || !peerE2eKey || !options.userId) {
|
||||||
|
setE2ePayloads({});
|
||||||
|
setE2eErrors({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
const nextPayloads: Record<string, E2EMessagePayload | null> = {};
|
||||||
|
const nextErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const message of options.messages) {
|
||||||
|
if (!message.isEncrypted || !message.content) continue;
|
||||||
|
const payload = await decryptE2EPayload(options.userId!, peerE2eKey, message.content);
|
||||||
|
if (payload) {
|
||||||
|
nextPayloads[message.id] = payload;
|
||||||
|
} else {
|
||||||
|
nextErrors[message.id] = '🔒 Не удалось расшифровать сообщение';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setE2ePayloads(nextPayloads);
|
||||||
|
setE2eErrors(nextErrors);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isE2E, options.messages, options.userId, peerE2eKey]);
|
||||||
|
|
||||||
|
const encryptOutgoing = useCallback(
|
||||||
|
async (payload: Parameters<typeof encryptE2EPayload>[2]) => {
|
||||||
|
if (!isE2E || !options.userId) {
|
||||||
|
throw new Error('E2E недоступен');
|
||||||
|
}
|
||||||
|
if (!peerE2eKey) {
|
||||||
|
throw new Error('Собеседник ещё не опубликовал ключ шифрования');
|
||||||
|
}
|
||||||
|
return encryptE2EPayload(options.userId, peerE2eKey, payload);
|
||||||
|
},
|
||||||
|
[isE2E, options.userId, peerE2eKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isE2E,
|
||||||
|
peerUserId,
|
||||||
|
peerE2eKey,
|
||||||
|
peerKeyLoading,
|
||||||
|
e2ePayloads,
|
||||||
|
e2eErrors,
|
||||||
|
encryptOutgoing
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,16 +19,19 @@ export function getPrimaryRoleLabel(user: PublicUser): string {
|
|||||||
|
|
||||||
export function getAdminLandingPath(user: PublicUser): string {
|
export function getAdminLandingPath(user: PublicUser): string {
|
||||||
if (user.canViewUsers || user.canManageUsers) return '/admin/users';
|
if (user.canViewUsers || user.canManageUsers) return '/admin/users';
|
||||||
|
if (user.canManageBots) return '/admin/bots';
|
||||||
if (user.canViewOAuth || user.canManageOAuth) return '/admin/oauth';
|
if (user.canViewOAuth || user.canManageOAuth) return '/admin/oauth';
|
||||||
if (user.canManageSettings) return '/admin/settings';
|
if (user.canManageSettings) return '/admin/settings';
|
||||||
if (user.canManageRoles) return '/admin/rbac';
|
if (user.canManageRoles) return '/admin/rbac';
|
||||||
return '/admin/oauth';
|
return '/admin/oauth';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canAccessAdminSection(user: PublicUser, section: 'users' | 'oauth' | 'settings' | 'rbac') {
|
export function canAccessAdminSection(user: PublicUser, section: 'users' | 'oauth' | 'settings' | 'rbac' | 'bots') {
|
||||||
switch (section) {
|
switch (section) {
|
||||||
case 'users':
|
case 'users':
|
||||||
return Boolean(user.canViewUsers || user.canManageUsers);
|
return Boolean(user.canViewUsers || user.canManageUsers);
|
||||||
|
case 'bots':
|
||||||
|
return Boolean(user.canManageBots || user.isSuperAdmin);
|
||||||
case 'oauth':
|
case 'oauth':
|
||||||
return Boolean(user.canViewOAuth || user.canManageOAuth);
|
return Boolean(user.canViewOAuth || user.canManageOAuth);
|
||||||
case 'settings':
|
case 'settings':
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export interface PublicUser {
|
|||||||
isVerified?: boolean;
|
isVerified?: boolean;
|
||||||
verificationIcon?: string | null;
|
verificationIcon?: string | null;
|
||||||
canVerifyUsers?: boolean;
|
canVerifyUsers?: boolean;
|
||||||
|
canManageBots?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
@@ -237,6 +238,10 @@ export interface FamilyInviteCandidate {
|
|||||||
hasAvatar?: boolean;
|
hasAvatar?: boolean;
|
||||||
isVerified?: boolean;
|
isVerified?: boolean;
|
||||||
verificationIcon?: string | null;
|
verificationIcon?: string | null;
|
||||||
|
isBot?: boolean;
|
||||||
|
botUsername?: string;
|
||||||
|
botId?: string;
|
||||||
|
ownerDisplayName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchFamilyInviteUsers(groupId: string, query: string, token?: string | null) {
|
export async function searchFamilyInviteUsers(groupId: string, query: string, token?: string | null) {
|
||||||
@@ -662,6 +667,8 @@ export interface FamilyMember {
|
|||||||
hasAvatar: boolean;
|
hasAvatar: boolean;
|
||||||
isVerified?: boolean;
|
isVerified?: boolean;
|
||||||
verificationIcon?: string | null;
|
verificationIcon?: string | null;
|
||||||
|
isBot?: boolean;
|
||||||
|
botUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FamilyGroup {
|
export interface FamilyGroup {
|
||||||
@@ -670,6 +677,27 @@ export interface FamilyGroup {
|
|||||||
name: string;
|
name: string;
|
||||||
hasAvatar: boolean;
|
hasAvatar: boolean;
|
||||||
members?: FamilyMember[];
|
members?: FamilyMember[];
|
||||||
|
botFatherAvailable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotChatMessage {
|
||||||
|
id: string;
|
||||||
|
direction: 'in' | 'out';
|
||||||
|
text: string;
|
||||||
|
messageType: string;
|
||||||
|
messageId: number;
|
||||||
|
createdAt: string;
|
||||||
|
replyMarkupJson?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotChatMeta {
|
||||||
|
botId?: string;
|
||||||
|
botOwnerId?: string;
|
||||||
|
botUsername?: string;
|
||||||
|
botDisplayName?: string;
|
||||||
|
composerWebAppUrl?: string | null;
|
||||||
|
composerMenuButtonJson?: string | null;
|
||||||
|
manageWebAppUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FamilyInvite {
|
export interface FamilyInvite {
|
||||||
@@ -702,6 +730,12 @@ export interface ChatPollOption {
|
|||||||
votedByMe: boolean;
|
votedByMe: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatMessageReaction {
|
||||||
|
emoji: string;
|
||||||
|
count: number;
|
||||||
|
reactedByMe: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -712,6 +746,7 @@ export interface ChatMessage {
|
|||||||
senderVerificationIcon?: string | null;
|
senderVerificationIcon?: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
isEncrypted?: boolean;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
@@ -727,6 +762,7 @@ export interface ChatMessage {
|
|||||||
isAnonymous: boolean;
|
isAnonymous: boolean;
|
||||||
options: ChatPollOption[];
|
options: ChatPollOption[];
|
||||||
};
|
};
|
||||||
|
reactions?: ChatMessageReaction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FamilyPresenceMember {
|
export interface FamilyPresenceMember {
|
||||||
@@ -758,6 +794,9 @@ export interface ChatRoom {
|
|||||||
groupId: string;
|
groupId: string;
|
||||||
type: string;
|
type: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
peerUserId?: string;
|
||||||
|
botUsername?: string;
|
||||||
|
isE2E?: boolean;
|
||||||
hasAvatar: boolean;
|
hasAvatar: boolean;
|
||||||
createdById?: string;
|
createdById?: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -785,6 +824,43 @@ export async function sendFamilyInvite(groupId: string, payload: { inviteeUserId
|
|||||||
return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token);
|
return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function addBotFatherToFamily(groupId: string, token?: string | null) {
|
||||||
|
return apiFetch<FamilyMember>(`/family/groups/${groupId}/botfather`, { method: 'POST', body: '{}' }, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addBotToFamily(groupId: string, botId: string, token?: string | null) {
|
||||||
|
return apiFetch<FamilyMember>(`/family/groups/${groupId}/bots`, { method: 'POST', body: JSON.stringify({ botId }) }, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBotChatMessages(botRef: string, token?: string | null) {
|
||||||
|
return apiFetch<{
|
||||||
|
messages?: BotChatMessage[];
|
||||||
|
botUsername?: string;
|
||||||
|
botDisplayName?: string;
|
||||||
|
botId?: string;
|
||||||
|
botOwnerId?: string;
|
||||||
|
composerWebAppUrl?: string;
|
||||||
|
composerMenuButtonJson?: string;
|
||||||
|
manageWebAppUrl?: string;
|
||||||
|
}>(`/bots/by-username/${encodeURIComponent(botRef)}/messages`, {}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendBotMessage(botRef: string, text: string, token?: string | null) {
|
||||||
|
return apiFetch<{ updateId?: number; messageId?: number; chatId?: string }>(
|
||||||
|
`/bots/by-username/${encodeURIComponent(botRef)}/messages`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ text }) },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitBotCallback(botRef: string, messageId: number, callbackData: string, token?: string | null) {
|
||||||
|
return apiFetch<{ updateId?: number; callbackQueryId?: string; messageId?: number }>(
|
||||||
|
`/bots/by-username/${encodeURIComponent(botRef)}/callback`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ messageId, callbackData }) },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchFamilyInvites(token?: string | null) {
|
export async function fetchFamilyInvites(token?: string | null) {
|
||||||
return apiFetch<{ invites?: FamilyInvite[] }>('/family/invites', {}, token);
|
return apiFetch<{ invites?: FamilyInvite[] }>('/family/invites', {}, token);
|
||||||
}
|
}
|
||||||
@@ -845,6 +921,7 @@ export async function sendChatMessage(
|
|||||||
body: {
|
body: {
|
||||||
type: string;
|
type: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
isEncrypted?: boolean;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
@@ -856,6 +933,111 @@ export async function sendChatMessage(
|
|||||||
return apiFetch<ChatMessage>(`/chat/rooms/${roomId}/messages`, { method: 'POST', body: JSON.stringify(body) }, token);
|
return apiFetch<ChatMessage>(`/chat/rooms/${roomId}/messages`, { method: 'POST', body: JSON.stringify(body) }, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchUserE2EPublicKey(userId: string, token?: string | null) {
|
||||||
|
return apiFetch<{ userId?: string; e2ePublicKey?: string }>(`/profile/users/${userId}/e2e-public-key`, {}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserE2EPublicKey(userId: string, publicKey: string, token?: string | null) {
|
||||||
|
return apiFetch<{ userId?: string; e2ePublicKey?: string }>(
|
||||||
|
`/profile/users/${userId}/e2e-public-key`,
|
||||||
|
{ method: 'PATCH', body: JSON.stringify({ publicKey }) },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedBot {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
tokenPrefix?: string;
|
||||||
|
description?: string | null;
|
||||||
|
aboutText?: string | null;
|
||||||
|
botPicUrl?: string | null;
|
||||||
|
menuButtonJson?: string | null;
|
||||||
|
webAppUrl?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
isSystemBot?: boolean;
|
||||||
|
ownerId?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
owner?: { id: string; displayName: string; username?: string };
|
||||||
|
messageCount?: number;
|
||||||
|
chatCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminBotMetrics {
|
||||||
|
totalBots?: number;
|
||||||
|
activeBots?: number;
|
||||||
|
blockedBots?: number;
|
||||||
|
totalMessages?: number;
|
||||||
|
totalChats?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminBots(
|
||||||
|
params: { search?: string; page?: number; limit?: number },
|
||||||
|
token?: string | null
|
||||||
|
) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.search?.trim()) query.set('search', params.search.trim());
|
||||||
|
if (params.page) query.set('page', String(params.page));
|
||||||
|
if (params.limit) query.set('limit', String(params.limit));
|
||||||
|
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||||
|
return apiFetch<{ bots?: ManagedBot[]; total?: number }>(`/admin/bots${suffix}`, {}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminBotMetrics(token?: string | null) {
|
||||||
|
return apiFetch<AdminBotMetrics>('/admin/bots/metrics', {}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAdminBotActive(botId: string, isActive: boolean, token?: string | null) {
|
||||||
|
return apiFetch<ManagedBot>(`/admin/bots/${botId}/active`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ isActive })
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createE2ERoom(groupId: string, peerUserId: string, token?: string | null) {
|
||||||
|
return apiFetch<ChatRoom>(`/chat/groups/${groupId}/e2e-rooms`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ peerUserId })
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMyBots(token?: string | null) {
|
||||||
|
return apiFetch<{ bots?: ManagedBot[]; total?: number }>('/bots', {}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createManagedBot(body: { name: string; username: string }, token?: string | null) {
|
||||||
|
return apiFetch<{ bot?: ManagedBot; token?: string }>('/bots', { method: 'POST', body: JSON.stringify(body) }, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBot(botId: string, token?: string | null) {
|
||||||
|
return apiFetch<ManagedBot>(`/bots/${botId}`, {}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateManagedBot(botId: string, body: { name?: string; username?: string }, token?: string | null) {
|
||||||
|
return apiFetch<ManagedBot>(`/bots/${botId}`, { method: 'PATCH', body: JSON.stringify(body) }, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateManagedBotProfile(
|
||||||
|
botId: string,
|
||||||
|
body: {
|
||||||
|
description?: string;
|
||||||
|
aboutText?: string;
|
||||||
|
botPicUrl?: string;
|
||||||
|
menuButtonUrl?: string;
|
||||||
|
menuButtonText?: string;
|
||||||
|
menuButtonJson?: string;
|
||||||
|
},
|
||||||
|
token?: string | null
|
||||||
|
) {
|
||||||
|
return apiFetch<ManagedBot>(`/bots/${botId}/profile`, { method: 'PATCH', body: JSON.stringify(body) }, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeManagedBotToken(botId: string, token?: string | null) {
|
||||||
|
return apiFetch<{ bot?: ManagedBot; token?: string }>(`/bots/${botId}/revoke-token`, { method: 'POST' }, token);
|
||||||
|
}
|
||||||
|
|
||||||
export async function voteChatPoll(messageId: string, optionIds: string[], token?: string | null) {
|
export async function voteChatPoll(messageId: string, optionIds: string[], token?: string | null) {
|
||||||
return apiFetch<ChatMessage>(`/chat/messages/${messageId}/vote`, { method: 'POST', body: JSON.stringify({ optionIds }) }, token);
|
return apiFetch<ChatMessage>(`/chat/messages/${messageId}/vote`, { method: 'POST', body: JSON.stringify({ optionIds }) }, token);
|
||||||
}
|
}
|
||||||
@@ -872,6 +1054,24 @@ export async function deleteChatMessage(messageId: string, token?: string | null
|
|||||||
return apiFetch<ChatMessage>(`/chat/messages/${messageId}`, { method: 'DELETE' }, token);
|
return apiFetch<ChatMessage>(`/chat/messages/${messageId}`, { method: 'DELETE' }, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleChatMessageReaction(messageId: string, emoji: string, token?: string | null) {
|
||||||
|
return apiFetch<ChatMessage>(`/chat/messages/${messageId}/reactions`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ emoji })
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forwardChatMessages(targetRoomId: string, messageIds: string[], token?: string | null) {
|
||||||
|
return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${targetRoomId}/forward`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ messageIds })
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteChatRoom(roomId: string, token?: string | null) {
|
||||||
|
return apiFetch<{ count?: number }>(`/chat/rooms/${roomId}`, { method: 'DELETE' }, token);
|
||||||
|
}
|
||||||
|
|
||||||
export async function markChatRoomRead(roomId: string, lastMessageId: string | undefined, token?: string | null) {
|
export async function markChatRoomRead(roomId: string, lastMessageId: string | undefined, token?: string | null) {
|
||||||
return apiFetch<{ count: number }>(`/chat/rooms/${roomId}/read`, {
|
return apiFetch<{ count: number }>(`/chat/rooms/${roomId}/read`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -879,6 +1079,10 @@ export async function markChatRoomRead(roomId: string, lastMessageId: string | u
|
|||||||
}, token);
|
}, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reportChatTyping(roomId: string, token?: string | null) {
|
||||||
|
return apiFetch<{ success?: boolean }>(`/chat/rooms/${roomId}/typing`, { method: 'POST' }, token);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchFamilyPresence(groupId: string, token?: string | null) {
|
export async function fetchFamilyPresence(groupId: string, token?: string | null) {
|
||||||
return apiFetch<FamilyPresence>(`/family/groups/${groupId}/presence`, {}, token);
|
return apiFetch<FamilyPresence>(`/family/groups/${groupId}/presence`, {}, token);
|
||||||
}
|
}
|
||||||
|
|||||||
46
apps/frontend/lib/bot-menu-button.ts
Normal file
46
apps/frontend/lib/bot-menu-button.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export type ComposerMenuButton = {
|
||||||
|
type: 'web_app';
|
||||||
|
text: string;
|
||||||
|
web_app: { url: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseComposerMenuButtonJson(raw?: string | null): ComposerMenuButton | null {
|
||||||
|
if (!raw?.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (
|
||||||
|
typeof parsed === 'object' &&
|
||||||
|
parsed !== null &&
|
||||||
|
(parsed as ComposerMenuButton).type === 'web_app' &&
|
||||||
|
typeof (parsed as ComposerMenuButton).text === 'string' &&
|
||||||
|
typeof (parsed as ComposerMenuButton).web_app?.url === 'string'
|
||||||
|
) {
|
||||||
|
const button = parsed as ComposerMenuButton;
|
||||||
|
const url = button.web_app.url.trim();
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { type: 'web_app', text: button.text.trim() || 'App', web_app: { url } };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveComposerMenuButton(options: {
|
||||||
|
composerMenuButtonJson?: string | null;
|
||||||
|
composerWebAppUrl?: string | null;
|
||||||
|
}): ComposerMenuButton | null {
|
||||||
|
const fromJson = parseComposerMenuButtonJson(options.composerMenuButtonJson);
|
||||||
|
if (fromJson) {
|
||||||
|
return fromJson;
|
||||||
|
}
|
||||||
|
const url = options.composerWebAppUrl?.trim();
|
||||||
|
if (url) {
|
||||||
|
return { type: 'web_app', text: 'App', web_app: { url } };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
95
apps/frontend/lib/bot-reply-markup.ts
Normal file
95
apps/frontend/lib/bot-reply-markup.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
export interface InlineKeyboardButton {
|
||||||
|
text: string;
|
||||||
|
callback_data?: string;
|
||||||
|
callbackData?: string;
|
||||||
|
url?: string;
|
||||||
|
web_app?: { url?: string };
|
||||||
|
webAppUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InlineKeyboardMarkup {
|
||||||
|
inline_keyboard?: InlineKeyboardButton[][];
|
||||||
|
reply_markup?: {
|
||||||
|
inline_keyboard?: InlineKeyboardButton[][];
|
||||||
|
};
|
||||||
|
kind?: 'inline';
|
||||||
|
rows?: InlineKeyboardButton[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeButton(button: InlineKeyboardButton): InlineKeyboardButton {
|
||||||
|
return {
|
||||||
|
text: button.text,
|
||||||
|
callback_data: button.callback_data ?? button.callbackData,
|
||||||
|
url: button.url,
|
||||||
|
web_app: button.web_app ?? (button.webAppUrl ? { url: button.webAppUrl } : undefined)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseInlineKeyboardMarkup(source: unknown): InlineKeyboardButton[][] {
|
||||||
|
if (!source) return [];
|
||||||
|
|
||||||
|
let raw: InlineKeyboardMarkup | null = null;
|
||||||
|
|
||||||
|
if (typeof source === 'string') {
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(source) as InlineKeyboardMarkup;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} else if (typeof source === 'object') {
|
||||||
|
raw = source as InlineKeyboardMarkup;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!raw) return [];
|
||||||
|
|
||||||
|
const nested = raw.reply_markup?.inline_keyboard;
|
||||||
|
if (Array.isArray(nested) && nested.length) {
|
||||||
|
return nested
|
||||||
|
.filter((row) => Array.isArray(row))
|
||||||
|
.map((row) => row.map(normalizeButton).filter((button) => Boolean(button.text?.trim())));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(raw.inline_keyboard) && raw.inline_keyboard.length) {
|
||||||
|
return raw.inline_keyboard
|
||||||
|
.filter((row) => Array.isArray(row))
|
||||||
|
.map((row) => row.map(normalizeButton).filter((button) => Boolean(button.text?.trim())));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.kind === 'inline' && Array.isArray(raw.rows)) {
|
||||||
|
return raw.rows
|
||||||
|
.filter((row) => Array.isArray(row))
|
||||||
|
.map((row) => row.map(normalizeButton).filter((button) => Boolean(button.text?.trim())));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeInlineKeyboardMarkup(rows: InlineKeyboardButton[][]): string | null {
|
||||||
|
if (!rows.length) return null;
|
||||||
|
return JSON.stringify({ inline_keyboard: rows.map((row) => row.map(normalizeButton)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMessageReplyMarkup(message: {
|
||||||
|
replyMarkupJson?: string | null;
|
||||||
|
replyMarkup?: unknown;
|
||||||
|
telegramReplyMarkup?: unknown;
|
||||||
|
reply_markup?: unknown;
|
||||||
|
}): InlineKeyboardButton[][] {
|
||||||
|
if (message.replyMarkupJson) {
|
||||||
|
const parsed = parseInlineKeyboardMarkup(message.replyMarkupJson);
|
||||||
|
if (parsed.length) return parsed;
|
||||||
|
}
|
||||||
|
if (message.telegramReplyMarkup) {
|
||||||
|
const parsed = parseInlineKeyboardMarkup(message.telegramReplyMarkup);
|
||||||
|
if (parsed.length) return parsed;
|
||||||
|
}
|
||||||
|
if (message.replyMarkup) {
|
||||||
|
const parsed = parseInlineKeyboardMarkup(message.replyMarkup);
|
||||||
|
if (parsed.length) return parsed;
|
||||||
|
}
|
||||||
|
if (message.reply_markup) {
|
||||||
|
const parsed = parseInlineKeyboardMarkup(message.reply_markup);
|
||||||
|
if (parsed.length) return parsed;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
89
apps/frontend/lib/chat-message-utils.ts
Normal file
89
apps/frontend/lib/chat-message-utils.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { ChatMessage } from '@/lib/api';
|
||||||
|
import { emojiIdToNative, resolveNativeEmojiContent } from '@/lib/emoji-native-map';
|
||||||
|
|
||||||
|
export const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏', '🔥', '👏'] as const;
|
||||||
|
|
||||||
|
export interface ForwardedFromMeta {
|
||||||
|
messageId: string;
|
||||||
|
roomId: string;
|
||||||
|
senderId: string;
|
||||||
|
senderName: string;
|
||||||
|
type: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseForwardedFrom(metadataJson?: string): ForwardedFromMeta | null {
|
||||||
|
if (!metadataJson) return null;
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(metadataJson) as { forwardedFrom?: ForwardedFromMeta };
|
||||||
|
const forwarded = meta.forwardedFrom;
|
||||||
|
return forwarded?.messageId ? forwarded : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageCopyText(message: ChatMessage, visibleText?: string) {
|
||||||
|
if (message.isDeleted) return '';
|
||||||
|
if (visibleText?.trim()) return resolveNativeEmojiContent(visibleText.trim());
|
||||||
|
if (message.content?.trim()) {
|
||||||
|
if (message.type === 'EMOJI') {
|
||||||
|
return emojiIdToNative(message.content.trim()) ?? message.content.trim();
|
||||||
|
}
|
||||||
|
return resolveNativeEmojiContent(message.content.trim());
|
||||||
|
}
|
||||||
|
if (message.type === 'POLL' && message.poll) return message.poll.question;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessagePreviewText(message: ChatMessage, visibleText?: string) {
|
||||||
|
if (message.type === 'SYSTEM') {
|
||||||
|
return resolveNativeEmojiContent(message.content?.trim() ?? 'Системное сообщение');
|
||||||
|
}
|
||||||
|
if (message.type === 'POLL' && message.poll) {
|
||||||
|
return `📊 Опрос: ${message.poll.question}`;
|
||||||
|
}
|
||||||
|
const text = getMessageCopyText(message, visibleText);
|
||||||
|
if (text) return text;
|
||||||
|
if (message.type === 'IMAGE') return '📷 Фото';
|
||||||
|
if (message.type === 'VOICE') return '🎤 Голосовое';
|
||||||
|
if (message.type === 'AUDIO') return '🎵 Аудио';
|
||||||
|
if (message.type === 'FILE') return '📄 Файл';
|
||||||
|
if (message.isEncrypted) return '🔒 Зашифрованное сообщение';
|
||||||
|
return 'Сообщение';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChatListPreviewText(message?: ChatMessage | null, fallback = '') {
|
||||||
|
if (!message || message.isDeleted) return fallback;
|
||||||
|
return getMessagePreviewText(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSystemMessage(message: ChatMessage) {
|
||||||
|
return message.type === 'SYSTEM';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSelectableMessage(message: ChatMessage) {
|
||||||
|
return !message.isDeleted && !isSystemMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isChatInteractiveTarget(target: EventTarget | null) {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
return Boolean(
|
||||||
|
target.closest('button, a, input, textarea, select, label, [contenteditable="true"], [data-chat-interactive="true"]')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canEditMessage(message: ChatMessage, userId?: string, isE2E?: boolean) {
|
||||||
|
return (
|
||||||
|
Boolean(userId && message.senderId === userId) &&
|
||||||
|
!message.isDeleted &&
|
||||||
|
!isSystemMessage(message) &&
|
||||||
|
!message.isEncrypted &&
|
||||||
|
!isE2E &&
|
||||||
|
(message.type === 'TEXT' || message.type === 'EMOJI')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canDeleteMessage(message: ChatMessage, userId?: string) {
|
||||||
|
return Boolean(userId && message.senderId === userId && !message.isDeleted && !isSystemMessage(message));
|
||||||
|
}
|
||||||
72
apps/frontend/lib/e2e-chat.ts
Normal file
72
apps/frontend/lib/e2e-chat.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { decryptBinary, decryptDirectMessage, encryptBinary, encryptDirectMessage } from '@/lib/e2e-crypto';
|
||||||
|
|
||||||
|
export type E2EMessageKind = 'text' | 'emoji' | 'image' | 'voice' | 'audio' | 'file';
|
||||||
|
|
||||||
|
export interface E2EMessagePayload {
|
||||||
|
kind: E2EMessageKind;
|
||||||
|
text?: string;
|
||||||
|
emojiId?: string;
|
||||||
|
fileName?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptE2EPayload(userId: string, peerPublicKey: string, payload: E2EMessagePayload) {
|
||||||
|
return encryptDirectMessage(userId, peerPublicKey, JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptE2EPayload(userId: string, peerPublicKey: string, encrypted: string): Promise<E2EMessagePayload | null> {
|
||||||
|
const decrypted = await decryptDirectMessage(userId, peerPublicKey, encrypted);
|
||||||
|
if (decrypted.startsWith('🔒')) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(decrypted) as E2EMessagePayload;
|
||||||
|
} catch {
|
||||||
|
return { kind: 'text', text: decrypted };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptE2EBlob(userId: string, peerPublicKey: string, data: ArrayBuffer) {
|
||||||
|
return encryptBinary(userId, peerPublicKey, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptE2EBlob(userId: string, peerPublicKey: string, encryptedBytes: ArrayBuffer) {
|
||||||
|
return decryptBinary(userId, peerPublicKey, encryptedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function e2eKindFromMessageType(type: string): E2EMessageKind {
|
||||||
|
switch (type) {
|
||||||
|
case 'EMOJI':
|
||||||
|
return 'emoji';
|
||||||
|
case 'IMAGE':
|
||||||
|
return 'image';
|
||||||
|
case 'VOICE':
|
||||||
|
return 'voice';
|
||||||
|
case 'AUDIO':
|
||||||
|
return 'audio';
|
||||||
|
case 'FILE':
|
||||||
|
return 'file';
|
||||||
|
default:
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readableE2EPayload(payload: E2EMessagePayload | null) {
|
||||||
|
if (!payload) return '🔒 Зашифрованное сообщение';
|
||||||
|
switch (payload.kind) {
|
||||||
|
case 'emoji':
|
||||||
|
return payload.emojiId ?? '';
|
||||||
|
case 'text':
|
||||||
|
return payload.text ?? '';
|
||||||
|
case 'voice':
|
||||||
|
return 'Голосовое сообщение';
|
||||||
|
case 'audio':
|
||||||
|
return 'Аудио';
|
||||||
|
case 'image':
|
||||||
|
return 'Фото';
|
||||||
|
case 'file':
|
||||||
|
return payload.fileName ?? 'Файл';
|
||||||
|
default:
|
||||||
|
return '🔒 Зашифрованное сообщение';
|
||||||
|
}
|
||||||
|
}
|
||||||
121
apps/frontend/lib/e2e-crypto.ts
Normal file
121
apps/frontend/lib/e2e-crypto.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
const STORAGE_PREFIX = 'lendry-e2e-private:';
|
||||||
|
const ALGORITHM = { name: 'ECDH', namedCurve: 'P-256' } as const;
|
||||||
|
const AES = { name: 'AES-GCM', length: 256 } as const;
|
||||||
|
|
||||||
|
interface StoredKeyMaterial {
|
||||||
|
privateJwk: JsonWebKey;
|
||||||
|
publicKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase64(buffer: ArrayBuffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
bytes.forEach((byte) => {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
});
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase64(value: string) {
|
||||||
|
const binary = atob(value);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPublicKey(key: CryptoKey) {
|
||||||
|
const raw = await crypto.subtle.exportKey('spki', key);
|
||||||
|
return toBase64(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPublicKey(base64: string) {
|
||||||
|
return crypto.subtle.importKey('spki', fromBase64(base64), ALGORITHM, true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStoredKeys(userId: string) {
|
||||||
|
const stored = localStorage.getItem(`${STORAGE_PREFIX}${userId}`);
|
||||||
|
if (!stored) return null;
|
||||||
|
const parsed = JSON.parse(stored) as StoredKeyMaterial;
|
||||||
|
const privateKey = await crypto.subtle.importKey('jwk', parsed.privateJwk, ALGORITHM, true, ['deriveKey']);
|
||||||
|
return { privateKey, publicKey: parsed.publicKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStoredKeys(userId: string, privateKey: CryptoKey, publicKey: string) {
|
||||||
|
const privateJwk = await crypto.subtle.exportKey('jwk', privateKey);
|
||||||
|
localStorage.setItem(
|
||||||
|
`${STORAGE_PREFIX}${userId}`,
|
||||||
|
JSON.stringify({ privateJwk, publicKey } satisfies StoredKeyMaterial)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureE2EKeyPair(userId: string) {
|
||||||
|
const existing = await loadStoredKeys(userId);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const keyPair = await crypto.subtle.generateKey(ALGORITHM, true, ['deriveKey']);
|
||||||
|
const publicKey = await exportPublicKey(keyPair.publicKey);
|
||||||
|
await saveStoredKeys(userId, keyPair.privateKey, publicKey);
|
||||||
|
return { privateKey: keyPair.privateKey, publicKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveAesKey(privateKey: CryptoKey, peerPublicKeyBase64: string) {
|
||||||
|
const peerPublicKey = await importPublicKey(peerPublicKeyBase64);
|
||||||
|
return crypto.subtle.deriveKey({ name: 'ECDH', public: peerPublicKey }, privateKey, AES, false, ['encrypt', 'decrypt']);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptDirectMessage(userId: string, peerPublicKeyBase64: string, plaintext: string) {
|
||||||
|
const keys = await ensureE2EKeyPair(userId);
|
||||||
|
const aesKey = await deriveAesKey(keys.privateKey, peerPublicKeyBase64);
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const encoded = new TextEncoder().encode(plaintext);
|
||||||
|
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, encoded);
|
||||||
|
return JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
iv: toBase64(iv.buffer),
|
||||||
|
ciphertext: toBase64(ciphertext)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptDirectMessage(userId: string, peerPublicKeyBase64: string, payload: string) {
|
||||||
|
const keys = await loadStoredKeys(userId);
|
||||||
|
if (!keys) {
|
||||||
|
return '🔒 Не удалось расшифровать сообщение';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payload) as { iv?: string; ciphertext?: string };
|
||||||
|
if (!parsed.iv || !parsed.ciphertext) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
const aesKey = await deriveAesKey(keys.privateKey, peerPublicKeyBase64);
|
||||||
|
const iv = new Uint8Array(fromBase64(parsed.iv));
|
||||||
|
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, fromBase64(parsed.ciphertext));
|
||||||
|
return new TextDecoder().decode(decrypted);
|
||||||
|
} catch {
|
||||||
|
return '🔒 Не удалось расшифровать сообщение';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptBinary(userId: string, peerPublicKeyBase64: string, data: ArrayBuffer) {
|
||||||
|
const keys = await ensureE2EKeyPair(userId);
|
||||||
|
const aesKey = await deriveAesKey(keys.privateKey, peerPublicKeyBase64);
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, data);
|
||||||
|
const packed = new Uint8Array(iv.byteLength + ciphertext.byteLength);
|
||||||
|
packed.set(iv, 0);
|
||||||
|
packed.set(new Uint8Array(ciphertext), iv.byteLength);
|
||||||
|
return packed.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptBinary(userId: string, peerPublicKeyBase64: string, packed: ArrayBuffer) {
|
||||||
|
const keys = await loadStoredKeys(userId);
|
||||||
|
if (!keys) {
|
||||||
|
throw new Error('Локальный E2E-ключ не найден');
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(packed);
|
||||||
|
const iv = bytes.slice(0, 12);
|
||||||
|
const ciphertext = bytes.slice(12);
|
||||||
|
const aesKey = await deriveAesKey(keys.privateKey, peerPublicKeyBase64);
|
||||||
|
return crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, ciphertext);
|
||||||
|
}
|
||||||
167
apps/frontend/lib/emoji-catalog.ts
Normal file
167
apps/frontend/lib/emoji-catalog.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
export type EmojiCategoryId = 'smileys' | 'gestures' | 'hearts' | 'animals' | 'food' | 'travel' | 'objects' | 'symbols';
|
||||||
|
|
||||||
|
export interface EmojiDefinition {
|
||||||
|
id: string;
|
||||||
|
category: EmojiCategoryId;
|
||||||
|
keywords: string[];
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMOJI_CATEGORIES: Array<{ id: EmojiCategoryId; label: string; icon: string }> = [
|
||||||
|
{ id: 'smileys', label: 'Смайлы', icon: 'e-grinning' },
|
||||||
|
{ id: 'gestures', label: 'Жесты', icon: 'e-thumbs-up' },
|
||||||
|
{ id: 'hearts', label: 'Сердца', icon: 'e-red-heart' },
|
||||||
|
{ id: 'animals', label: 'Животные', icon: 'e-dog' },
|
||||||
|
{ id: 'food', label: 'Еда', icon: 'e-pizza' },
|
||||||
|
{ id: 'travel', label: 'Путешествия', icon: 'e-car' },
|
||||||
|
{ id: 'objects', label: 'Предметы', icon: 'e-bulb' },
|
||||||
|
{ id: 'symbols', label: 'Символы', icon: 'e-check' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EMOJI_DEFINITIONS: EmojiDefinition[] = [
|
||||||
|
{ id: 'e-grinning', category: 'smileys', label: 'Улыбка', keywords: ['радость', 'смех', 'улыбка'] },
|
||||||
|
{ id: 'e-smile', category: 'smileys', label: 'Улыбка глаза', keywords: ['улыбка', 'радость'] },
|
||||||
|
{ id: 'e-laugh', category: 'smileys', label: 'Смех', keywords: ['смех', 'lol', 'ха'] },
|
||||||
|
{ id: 'e-rofl', category: 'smileys', label: 'Катаюсь', keywords: ['ржу', 'смех'] },
|
||||||
|
{ id: 'e-wink', category: 'smileys', label: 'Подмигивание', keywords: ['подмигнуть'] },
|
||||||
|
{ id: 'e-blush', category: 'smileys', label: 'Смущение', keywords: ['румянец', 'мило'] },
|
||||||
|
{ id: 'e-innocent', category: 'smileys', label: 'Невинность', keywords: ['ангел', 'невинный'] },
|
||||||
|
{ id: 'e-slight-smile', category: 'smileys', label: 'Лёгкая улыбка', keywords: ['улыбка'] },
|
||||||
|
{ id: 'e-thinking', category: 'smileys', label: 'Думаю', keywords: ['думаю', 'хм'] },
|
||||||
|
{ id: 'e-neutral', category: 'smileys', label: 'Нейтрально', keywords: ['нейтрально'] },
|
||||||
|
{ id: 'e-expressionless', category: 'smileys', label: 'Без эмоций', keywords: ['пусто'] },
|
||||||
|
{ id: 'e-sleeping', category: 'smileys', label: 'Сплю', keywords: ['сон', 'спать', 'zzz'] },
|
||||||
|
{ id: 'e-tired', category: 'smileys', label: 'Устал', keywords: ['усталость'] },
|
||||||
|
{ id: 'e-cry', category: 'smileys', label: 'Плач', keywords: ['грусть', 'слёзы'] },
|
||||||
|
{ id: 'e-sob', category: 'smileys', label: 'Рыдаю', keywords: ['слёзы', 'грусть'] },
|
||||||
|
{ id: 'e-angry', category: 'smileys', label: 'Злость', keywords: ['злой', 'гнев'] },
|
||||||
|
{ id: 'e-rage', category: 'smileys', label: 'Ярость', keywords: ['злость'] },
|
||||||
|
{ id: 'e-shock', category: 'smileys', label: 'Шок', keywords: ['удивление', 'шок'] },
|
||||||
|
{ id: 'e-fear', category: 'smileys', label: 'Страх', keywords: ['страх', 'испуг'] },
|
||||||
|
{ id: 'e-cool', category: 'smileys', label: 'Круто', keywords: ['крутой', 'очки'] },
|
||||||
|
{ id: 'e-nerd', category: 'smileys', label: 'Ботан', keywords: ['очки', 'умный'] },
|
||||||
|
{ id: 'e-sick', category: 'smileys', label: 'Болею', keywords: ['больной', 'маска'] },
|
||||||
|
{ id: 'e-mask', category: 'smileys', label: 'Маска', keywords: ['маска', 'больной'] },
|
||||||
|
{ id: 'e-party', category: 'smileys', label: 'Праздник', keywords: ['вечеринка', 'шапка'] },
|
||||||
|
{ id: 'e-star-eyes', category: 'smileys', label: 'Звёзды', keywords: ['восторг', 'звёзды'] },
|
||||||
|
{ id: 'e-heart-eyes', category: 'smileys', label: 'Влюблён', keywords: ['любовь', 'сердце'] },
|
||||||
|
{ id: 'e-kiss', category: 'smileys', label: 'Поцелуй', keywords: ['поцелуй'] },
|
||||||
|
{ id: 'e-tongue', category: 'smileys', label: 'Язык', keywords: ['шутка', 'язык'] },
|
||||||
|
{ id: 'e-sweat', category: 'smileys', label: 'Пот', keywords: ['нервы', 'пот'] },
|
||||||
|
{ id: 'e-dizzy', category: 'smileys', label: 'Головокружение', keywords: ['dizzy'] },
|
||||||
|
|
||||||
|
{ id: 'e-thumbs-up', category: 'gestures', label: 'Лайк', keywords: ['лайк', 'ok', 'да'] },
|
||||||
|
{ id: 'e-thumbs-down', category: 'gestures', label: 'Дизлайк', keywords: ['нет', 'минус'] },
|
||||||
|
{ id: 'e-clap', category: 'gestures', label: 'Аплодисменты', keywords: ['хлопать', 'браво'] },
|
||||||
|
{ id: 'e-wave', category: 'gestures', label: 'Привет', keywords: ['привет', 'рука'] },
|
||||||
|
{ id: 'e-pray', category: 'gestures', label: 'Молитва', keywords: ['спасибо', 'please'] },
|
||||||
|
{ id: 'e-muscle', category: 'gestures', label: 'Сила', keywords: ['сила', 'качок'] },
|
||||||
|
{ id: 'e-ok-hand', category: 'gestures', label: 'Ок', keywords: ['ok', 'хорошо'] },
|
||||||
|
{ id: 'e-victory', category: 'gestures', label: 'Победа', keywords: ['peace', 'v'] },
|
||||||
|
{ id: 'e-crossed-fingers', category: 'gestures', label: 'Удачи', keywords: ['удача'] },
|
||||||
|
{ id: 'e-point-up', category: 'gestures', label: 'Указать', keywords: ['вверх'] },
|
||||||
|
{ id: 'e-point-down', category: 'gestures', label: 'Вниз', keywords: ['вниз'] },
|
||||||
|
{ id: 'e-raised-hand', category: 'gestures', label: 'Рука', keywords: ['стоп', 'рука'] },
|
||||||
|
{ id: 'e-fist', category: 'gestures', label: 'Кулак', keywords: ['кулак', 'сила'] },
|
||||||
|
{ id: 'e-handshake', category: 'gestures', label: 'Рукопожатие', keywords: ['deal', 'договор'] },
|
||||||
|
{ id: 'e-writing', category: 'gestures', label: 'Пишу', keywords: ['писать'] },
|
||||||
|
|
||||||
|
{ id: 'e-red-heart', category: 'hearts', label: 'Сердце', keywords: ['любовь', 'сердце'] },
|
||||||
|
{ id: 'e-orange-heart', category: 'hearts', label: 'Оранжевое', keywords: ['сердце'] },
|
||||||
|
{ id: 'e-yellow-heart', category: 'hearts', label: 'Жёлтое', keywords: ['сердце'] },
|
||||||
|
{ id: 'e-green-heart', category: 'hearts', label: 'Зелёное', keywords: ['сердце'] },
|
||||||
|
{ id: 'e-blue-heart', category: 'hearts', label: 'Синее', keywords: ['сердце'] },
|
||||||
|
{ id: 'e-purple-heart', category: 'hearts', label: 'Фиолетовое', keywords: ['сердце'] },
|
||||||
|
{ id: 'e-black-heart', category: 'hearts', label: 'Чёрное', keywords: ['сердце'] },
|
||||||
|
{ id: 'e-white-heart', category: 'hearts', label: 'Белое', keywords: ['сердце'] },
|
||||||
|
{ id: 'e-broken-heart', category: 'hearts', label: 'Разбитое', keywords: ['грусть'] },
|
||||||
|
{ id: 'e-sparkling-heart', category: 'hearts', label: 'Искры', keywords: ['любовь'] },
|
||||||
|
{ id: 'e-two-hearts', category: 'hearts', label: 'Два сердца', keywords: ['любовь'] },
|
||||||
|
{ id: 'e-revolving-hearts', category: 'hearts', label: 'Вращение', keywords: ['любовь'] },
|
||||||
|
|
||||||
|
{ id: 'e-dog', category: 'animals', label: 'Собака', keywords: ['собака', 'пёс'] },
|
||||||
|
{ id: 'e-cat', category: 'animals', label: 'Кошка', keywords: ['кошка', 'кот'] },
|
||||||
|
{ id: 'e-mouse', category: 'animals', label: 'Мышь', keywords: ['мышь'] },
|
||||||
|
{ id: 'e-rabbit', category: 'animals', label: 'Кролик', keywords: ['кролик'] },
|
||||||
|
{ id: 'e-bear', category: 'animals', label: 'Медведь', keywords: ['медведь'] },
|
||||||
|
{ id: 'e-panda', category: 'animals', label: 'Панда', keywords: ['панда'] },
|
||||||
|
{ id: 'e-lion', category: 'animals', label: 'Лев', keywords: ['лев'] },
|
||||||
|
{ id: 'e-tiger', category: 'animals', label: 'Тигр', keywords: ['тигр'] },
|
||||||
|
{ id: 'e-fox', category: 'animals', label: 'Лиса', keywords: ['лиса'] },
|
||||||
|
{ id: 'e-wolf', category: 'animals', label: 'Волк', keywords: ['волк'] },
|
||||||
|
{ id: 'e-monkey', category: 'animals', label: 'Обезьяна', keywords: ['обезьяна'] },
|
||||||
|
{ id: 'e-chicken', category: 'animals', label: 'Курица', keywords: ['курица'] },
|
||||||
|
{ id: 'e-penguin', category: 'animals', label: 'Пингвин', keywords: ['пингвин'] },
|
||||||
|
{ id: 'e-unicorn', category: 'animals', label: 'Единорог', keywords: ['единорог'] },
|
||||||
|
{ id: 'e-butterfly', category: 'animals', label: 'Бабочка', keywords: ['бабочка'] },
|
||||||
|
|
||||||
|
{ id: 'e-pizza', category: 'food', label: 'Пицца', keywords: ['пицца', 'еда'] },
|
||||||
|
{ id: 'e-burger', category: 'food', label: 'Бургер', keywords: ['бургер'] },
|
||||||
|
{ id: 'e-fries', category: 'food', label: 'Фри', keywords: ['картошка'] },
|
||||||
|
{ id: 'e-hotdog', category: 'food', label: 'Хот-дог', keywords: ['хотдог'] },
|
||||||
|
{ id: 'e-cake', category: 'food', label: 'Торт', keywords: ['торт', 'день рождения'] },
|
||||||
|
{ id: 'e-cookie', category: 'food', label: 'Печенье', keywords: ['печенье'] },
|
||||||
|
{ id: 'e-coffee', category: 'food', label: 'Кофе', keywords: ['кофе'] },
|
||||||
|
{ id: 'e-beer', category: 'food', label: 'Пиво', keywords: ['пиво'] },
|
||||||
|
{ id: 'e-wine', category: 'food', label: 'Вино', keywords: ['вино'] },
|
||||||
|
{ id: 'e-apple', category: 'food', label: 'Яблоко', keywords: ['яблоко', 'фрукт'] },
|
||||||
|
{ id: 'e-banana', category: 'food', label: 'Банан', keywords: ['банан'] },
|
||||||
|
{ id: 'e-grape', category: 'food', label: 'Виноград', keywords: ['виноград'] },
|
||||||
|
{ id: 'e-watermelon', category: 'food', label: 'Арбуз', keywords: ['арбуз'] },
|
||||||
|
{ id: 'e-egg', category: 'food', label: 'Яйцо', keywords: ['яйцо'] },
|
||||||
|
{ id: 'e-bread', category: 'food', label: 'Хлеб', keywords: ['хлеб'] },
|
||||||
|
|
||||||
|
{ id: 'e-car', category: 'travel', label: 'Авто', keywords: ['машина', 'авто'] },
|
||||||
|
{ id: 'e-bus', category: 'travel', label: 'Автобус', keywords: ['автобус'] },
|
||||||
|
{ id: 'e-train', category: 'travel', label: 'Поезд', keywords: ['поезд'] },
|
||||||
|
{ id: 'e-airplane', category: 'travel', label: 'Самолёт', keywords: ['самолёт'] },
|
||||||
|
{ id: 'e-rocket', category: 'travel', label: 'Ракета', keywords: ['космос', 'ракета'] },
|
||||||
|
{ id: 'e-ship', category: 'travel', label: 'Корабль', keywords: ['корабль'] },
|
||||||
|
{ id: 'e-bike', category: 'travel', label: 'Велосипед', keywords: ['велосипед'] },
|
||||||
|
{ id: 'e-house', category: 'travel', label: 'Дом', keywords: ['дом'] },
|
||||||
|
{ id: 'e-mountain', category: 'travel', label: 'Гора', keywords: ['гора'] },
|
||||||
|
{ id: 'e-beach', category: 'travel', label: 'Пляж', keywords: ['море', 'пляж'] },
|
||||||
|
|
||||||
|
{ id: 'e-bulb', category: 'objects', label: 'Лампочка', keywords: ['идея', 'лампа'] },
|
||||||
|
{ id: 'e-phone', category: 'objects', label: 'Телефон', keywords: ['телефон'] },
|
||||||
|
{ id: 'e-laptop', category: 'objects', label: 'Ноутбук', keywords: ['компьютер'] },
|
||||||
|
{ id: 'e-camera', category: 'objects', label: 'Камера', keywords: ['фото'] },
|
||||||
|
{ id: 'e-gift', category: 'objects', label: 'Подарок', keywords: ['подарок'] },
|
||||||
|
{ id: 'e-balloon', category: 'objects', label: 'Шарик', keywords: ['праздник'] },
|
||||||
|
{ id: 'e-book', category: 'objects', label: 'Книга', keywords: ['книга'] },
|
||||||
|
{ id: 'e-key', category: 'objects', label: 'Ключ', keywords: ['ключ'] },
|
||||||
|
{ id: 'e-lock', category: 'objects', label: 'Замок', keywords: ['замок', 'безопасность'] },
|
||||||
|
{ id: 'e-clock', category: 'objects', label: 'Часы', keywords: ['время'] },
|
||||||
|
{ id: 'e-music', category: 'objects', label: 'Музыка', keywords: ['нота', 'музыка'] },
|
||||||
|
{ id: 'e-game', category: 'objects', label: 'Игра', keywords: ['геймпад', 'игра'] },
|
||||||
|
|
||||||
|
{ id: 'e-check', category: 'symbols', label: 'Галочка', keywords: ['да', 'ok'] },
|
||||||
|
{ id: 'e-cross', category: 'symbols', label: 'Крест', keywords: ['нет', 'ошибка'] },
|
||||||
|
{ id: 'e-exclamation', category: 'symbols', label: 'Внимание', keywords: ['!', 'важно'] },
|
||||||
|
{ id: 'e-question', category: 'symbols', label: 'Вопрос', keywords: ['?', 'вопрос'] },
|
||||||
|
{ id: 'e-fire', category: 'symbols', label: 'Огонь', keywords: ['огонь', 'hot'] },
|
||||||
|
{ id: 'e-star', category: 'symbols', label: 'Звезда', keywords: ['звезда'] },
|
||||||
|
{ id: 'e-sparkles', category: 'symbols', label: 'Искры', keywords: ['блеск', 'новый'] },
|
||||||
|
{ id: 'e-100', category: 'symbols', label: '100', keywords: ['сто', 'идеально'] },
|
||||||
|
{ id: 'e-plus', category: 'symbols', label: 'Плюс', keywords: ['плюс'] },
|
||||||
|
{ id: 'e-minus', category: 'symbols', label: 'Минус', keywords: ['минус'] },
|
||||||
|
{ id: 'e-warning', category: 'symbols', label: 'Предупреждение', keywords: ['warning'] },
|
||||||
|
{ id: 'e-recycle', category: 'symbols', label: 'Recycle', keywords: ['эко'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EMOJI_BY_ID = new Map(EMOJI_DEFINITIONS.map((item) => [item.id, item]));
|
||||||
|
|
||||||
|
export function isEmojiId(value: string) {
|
||||||
|
return EMOJI_BY_ID.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchEmojis(query: string) {
|
||||||
|
const normalized = query.trim().toLowerCase();
|
||||||
|
if (!normalized) return EMOJI_DEFINITIONS;
|
||||||
|
return EMOJI_DEFINITIONS.filter(
|
||||||
|
(item) =>
|
||||||
|
item.label.toLowerCase().includes(normalized) ||
|
||||||
|
item.id.includes(normalized) ||
|
||||||
|
item.keywords.some((keyword) => keyword.includes(normalized))
|
||||||
|
);
|
||||||
|
}
|
||||||
133
apps/frontend/lib/emoji-native-map.ts
Normal file
133
apps/frontend/lib/emoji-native-map.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
export const EMOJI_NATIVE_MAP: Record<string, string> = {
|
||||||
|
'e-grinning': '😀',
|
||||||
|
'e-smile': '😊',
|
||||||
|
'e-laugh': '😄',
|
||||||
|
'e-rofl': '🤣',
|
||||||
|
'e-wink': '😉',
|
||||||
|
'e-blush': '😊',
|
||||||
|
'e-innocent': '😇',
|
||||||
|
'e-slight-smile': '🙂',
|
||||||
|
'e-thinking': '🤔',
|
||||||
|
'e-neutral': '😐',
|
||||||
|
'e-expressionless': '😑',
|
||||||
|
'e-sleeping': '😴',
|
||||||
|
'e-tired': '😫',
|
||||||
|
'e-cry': '😢',
|
||||||
|
'e-sob': '😭',
|
||||||
|
'e-angry': '😠',
|
||||||
|
'e-rage': '😡',
|
||||||
|
'e-shock': '😮',
|
||||||
|
'e-fear': '😨',
|
||||||
|
'e-cool': '😎',
|
||||||
|
'e-nerd': '🤓',
|
||||||
|
'e-sick': '🤒',
|
||||||
|
'e-mask': '😷',
|
||||||
|
'e-party': '🥳',
|
||||||
|
'e-star-eyes': '🤩',
|
||||||
|
'e-heart-eyes': '😍',
|
||||||
|
'e-kiss': '😘',
|
||||||
|
'e-tongue': '😛',
|
||||||
|
'e-sweat': '😅',
|
||||||
|
'e-dizzy': '😵',
|
||||||
|
'e-thumbs-up': '👍',
|
||||||
|
'e-thumbs-down': '👎',
|
||||||
|
'e-clap': '👏',
|
||||||
|
'e-wave': '👋',
|
||||||
|
'e-pray': '🙏',
|
||||||
|
'e-muscle': '💪',
|
||||||
|
'e-ok-hand': '👌',
|
||||||
|
'e-victory': '✌️',
|
||||||
|
'e-crossed-fingers': '🤞',
|
||||||
|
'e-point-up': '☝️',
|
||||||
|
'e-point-down': '👇',
|
||||||
|
'e-raised-hand': '✋',
|
||||||
|
'e-fist': '✊',
|
||||||
|
'e-handshake': '🤝',
|
||||||
|
'e-writing': '✍️',
|
||||||
|
'e-red-heart': '❤️',
|
||||||
|
'e-orange-heart': '🧡',
|
||||||
|
'e-yellow-heart': '💛',
|
||||||
|
'e-green-heart': '💚',
|
||||||
|
'e-blue-heart': '💙',
|
||||||
|
'e-purple-heart': '💜',
|
||||||
|
'e-black-heart': '🖤',
|
||||||
|
'e-white-heart': '🤍',
|
||||||
|
'e-broken-heart': '💔',
|
||||||
|
'e-sparkling-heart': '💖',
|
||||||
|
'e-two-hearts': '💕',
|
||||||
|
'e-revolving-hearts': '💞',
|
||||||
|
'e-dog': '🐶',
|
||||||
|
'e-cat': '🐱',
|
||||||
|
'e-mouse': '🐭',
|
||||||
|
'e-rabbit': '🐰',
|
||||||
|
'e-bear': '🐻',
|
||||||
|
'e-panda': '🐼',
|
||||||
|
'e-lion': '🦁',
|
||||||
|
'e-tiger': '🐯',
|
||||||
|
'e-fox': '🦊',
|
||||||
|
'e-wolf': '🦊',
|
||||||
|
'e-monkey': '🐵',
|
||||||
|
'e-chicken': '🐔',
|
||||||
|
'e-penguin': '🐧',
|
||||||
|
'e-unicorn': '🦄',
|
||||||
|
'e-butterfly': '🦋',
|
||||||
|
'e-pizza': '🍕',
|
||||||
|
'e-burger': '🍔',
|
||||||
|
'e-fries': '🍟',
|
||||||
|
'e-hotdog': '🌭',
|
||||||
|
'e-cake': '🎂',
|
||||||
|
'e-cookie': '🍪',
|
||||||
|
'e-coffee': '☕',
|
||||||
|
'e-beer': '🍺',
|
||||||
|
'e-wine': '🍷',
|
||||||
|
'e-apple': '🍎',
|
||||||
|
'e-banana': '🍌',
|
||||||
|
'e-grape': '🍇',
|
||||||
|
'e-watermelon': '🍉',
|
||||||
|
'e-egg': '🥚',
|
||||||
|
'e-bread': '🍞',
|
||||||
|
'e-car': '🚗',
|
||||||
|
'e-bus': '🚌',
|
||||||
|
'e-train': '🚆',
|
||||||
|
'e-airplane': '✈️',
|
||||||
|
'e-rocket': '🚀',
|
||||||
|
'e-ship': '🚢',
|
||||||
|
'e-bike': '🚲',
|
||||||
|
'e-house': '🏠',
|
||||||
|
'e-mountain': '⛰️',
|
||||||
|
'e-beach': '🏖️',
|
||||||
|
'e-bulb': '💡',
|
||||||
|
'e-phone': '📱',
|
||||||
|
'e-laptop': '💻',
|
||||||
|
'e-camera': '📷',
|
||||||
|
'e-gift': '🎁',
|
||||||
|
'e-balloon': '🎈',
|
||||||
|
'e-book': '📚',
|
||||||
|
'e-key': '🔑',
|
||||||
|
'e-lock': '🔒',
|
||||||
|
'e-clock': '🕐',
|
||||||
|
'e-music': '🎵',
|
||||||
|
'e-game': '🎮',
|
||||||
|
'e-check': '✅',
|
||||||
|
'e-cross': '❌',
|
||||||
|
'e-exclamation': '❗',
|
||||||
|
'e-question': '❓',
|
||||||
|
'e-fire': '🔥',
|
||||||
|
'e-star': '⭐',
|
||||||
|
'e-sparkles': '✨',
|
||||||
|
'e-100': '💯',
|
||||||
|
'e-plus': '➕',
|
||||||
|
'e-minus': '➖',
|
||||||
|
'e-warning': '⚠️',
|
||||||
|
'e-recycle': '♻️'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function emojiIdToNative(id: string): string | null {
|
||||||
|
return EMOJI_NATIVE_MAP[id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNativeEmojiContent(content?: string | null): string {
|
||||||
|
if (!content) return '';
|
||||||
|
if (EMOJI_NATIVE_MAP[content]) return EMOJI_NATIVE_MAP[content]!;
|
||||||
|
return content.replace(/:([a-z0-9-]+):/g, (match, slug) => EMOJI_NATIVE_MAP[`e-${slug}`] ?? match);
|
||||||
|
}
|
||||||
@@ -1,28 +1,64 @@
|
|||||||
import { ChatRoom, createChatRoom, fetchChatRooms } from '@/lib/api';
|
import { ChatRoom, fetchChatRooms } from '@/lib/api';
|
||||||
|
|
||||||
|
const ACTIVE_ROOM_STORAGE_PREFIX = 'lendry-family-active-room:';
|
||||||
|
|
||||||
|
export function readStoredActiveRoomId(groupId: string) {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return localStorage.getItem(`${ACTIVE_ROOM_STORAGE_PREFIX}${groupId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeActiveRoomId(groupId: string, roomId: string | null) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const key = `${ACTIVE_ROOM_STORAGE_PREFIX}${groupId}`;
|
||||||
|
if (!roomId) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.setItem(key, roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMemberRoom(
|
||||||
|
rooms: ChatRoom[],
|
||||||
|
memberUserId: string,
|
||||||
|
currentUserId: string,
|
||||||
|
options?: { isBot?: boolean; e2e?: boolean }
|
||||||
|
) {
|
||||||
|
const targetType = options?.isBot ? 'BOT' : options?.e2e ? 'E2E' : 'DIRECT';
|
||||||
|
return rooms.find(
|
||||||
|
(room) =>
|
||||||
|
room.type === targetType &&
|
||||||
|
room.members.some((member) => member.userId === memberUserId) &&
|
||||||
|
room.members.some((member) => member.userId === currentUserId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function findOrCreateMemberChat(
|
export async function findOrCreateMemberChat(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
memberUserId: string,
|
memberUserId: string,
|
||||||
memberName: string,
|
_memberName: string,
|
||||||
currentUserId: string,
|
currentUserId: string,
|
||||||
token: string
|
token: string
|
||||||
): Promise<ChatRoom> {
|
) {
|
||||||
const response = await fetchChatRooms(groupId, token);
|
const response = await fetchChatRooms(groupId, token);
|
||||||
const rooms = response.rooms ?? [];
|
const rooms = response.rooms ?? [];
|
||||||
const existing = rooms.find(
|
const member = rooms.find(
|
||||||
(room) =>
|
(room) =>
|
||||||
room.type === 'GROUP' &&
|
(room.type === 'DIRECT' || room.type === 'BOT') &&
|
||||||
room.members.length === 2 &&
|
room.members.some((item) => item.userId === memberUserId) &&
|
||||||
room.members.some((member) => member.userId === memberUserId) &&
|
room.members.some((item) => item.userId === currentUserId)
|
||||||
room.members.some((member) => member.userId === currentUserId)
|
|
||||||
);
|
);
|
||||||
if (existing) return existing;
|
if (member) return member;
|
||||||
return createChatRoom(groupId, memberName, [memberUserId], token);
|
throw new Error('Чат ещё не создан. Обновите страницу семьи.');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function roomDisplayLabel(room: ChatRoom, currentUserId: string) {
|
export function roomDisplayLabel(room: ChatRoom, currentUserId: string) {
|
||||||
if (room.type === 'GENERAL') return room.name;
|
if (room.type === 'GENERAL') return room.name;
|
||||||
if (room.members.length === 2) {
|
if (room.type === 'BOT') return room.name;
|
||||||
|
if (room.type === 'E2E') {
|
||||||
|
const other = room.members.find((member) => member.userId !== currentUserId);
|
||||||
|
return other ? `🔒 ${other.displayName}` : '🔒 Секретный чат';
|
||||||
|
}
|
||||||
|
if (room.type === 'DIRECT' || room.members.length === 2) {
|
||||||
const other = room.members.find((member) => member.userId !== currentUserId);
|
const other = room.members.find((member) => member.userId !== currentUserId);
|
||||||
if (other) return other.displayName;
|
if (other) return other.displayName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,15 +42,21 @@ export function buildOAuthEndpoints(apiBase: string): OAuthEndpoints {
|
|||||||
|
|
||||||
export function buildAuthorizeUrl(
|
export function buildAuthorizeUrl(
|
||||||
apiBase: string,
|
apiBase: string,
|
||||||
params: { clientId: string; redirectUri: string; scope: string; userId?: string; state?: string }
|
params: { clientId: string; redirectUri: string; scope: string; userId?: string; state?: string; useStandardParams?: boolean }
|
||||||
) {
|
) {
|
||||||
const url = new URL(`${normalizeBaseUrl(apiBase)}/oauth/authorize`);
|
const url = new URL(`${normalizeBaseUrl(apiBase)}/oauth/authorize`);
|
||||||
url.searchParams.set('clientId', params.clientId);
|
if (params.useStandardParams ?? true) {
|
||||||
url.searchParams.set('redirectUri', params.redirectUri);
|
url.searchParams.set('client_id', params.clientId);
|
||||||
url.searchParams.set('scope', params.scope);
|
url.searchParams.set('redirect_uri', params.redirectUri);
|
||||||
url.searchParams.set('userId', params.userId ?? 'USER_ID_AFTER_LOGIN');
|
url.searchParams.set('response_type', 'code');
|
||||||
if (params.state) {
|
url.searchParams.set('scope', params.scope);
|
||||||
url.searchParams.set('state', params.state);
|
if (params.state) url.searchParams.set('state', params.state);
|
||||||
|
} else {
|
||||||
|
url.searchParams.set('clientId', params.clientId);
|
||||||
|
url.searchParams.set('redirectUri', params.redirectUri);
|
||||||
|
url.searchParams.set('scope', params.scope);
|
||||||
|
if (params.userId) url.searchParams.set('userId', params.userId);
|
||||||
|
if (params.state) url.searchParams.set('state', params.state);
|
||||||
}
|
}
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
4
apps/frontend/next-env.d.ts
vendored
4
apps/frontend/next-env.d.ts
vendored
@@ -1,4 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// This file is generated by Next.js and kept for TypeScript compatibility.
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-context-menu": "^2.3.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
|||||||
1
apps/frontend/public/emojis/lendry-emojis.svg
Normal file
1
apps/frontend/public/emojis/lendry-emojis.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 26 KiB |
119
apps/frontend/scripts/generate-emoji-sprite.mjs
Normal file
119
apps/frontend/scripts/generate-emoji-sprite.mjs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const catalogPath = path.join(__dirname, '../lib/emoji-catalog.ts');
|
||||||
|
const src = fs.readFileSync(catalogPath, 'utf8');
|
||||||
|
const ids = [...src.matchAll(/id: '(e-[^']+)'/g)].map((match) => match[1]);
|
||||||
|
|
||||||
|
function face(id, mouth, extra = '') {
|
||||||
|
return `<symbol id="${id}" viewBox="0 0 36 36"><circle cx="18" cy="18" r="16" fill="#FFCC4D"/><ellipse cx="12" cy="15" rx="2.2" ry="3" fill="#664500"/><ellipse cx="24" cy="15" rx="2.2" ry="3" fill="#664500"/>${mouth}${extra}</symbol>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function heart(id, color) {
|
||||||
|
return `<symbol id="${id}" viewBox="0 0 36 36"><path d="M18 30s-10-6.5-10-14a5.5 5.5 0 0 1 10-3 5.5 5.5 0 0 1 10 3c0 7.5-10 14-10 14z" fill="${color}"/></symbol>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function circle(id, color, label) {
|
||||||
|
return `<symbol id="${id}" viewBox="0 0 36 36"><circle cx="18" cy="18" r="14" fill="${color}"/><text x="18" y="22" text-anchor="middle" font-size="10" fill="#fff" font-family="Arial,sans-serif">${label}</text></symbol>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mouthSmile =
|
||||||
|
'<path d="M10 21c2.5 3 13.5 3 16 0" stroke="#664500" stroke-width="2" fill="none" stroke-linecap="round"/>';
|
||||||
|
const mouthLaugh = '<path d="M10 19c2 5 14 5 16 0" fill="#664500"/>';
|
||||||
|
const mouthSad =
|
||||||
|
'<path d="M10 24c2.5-3 13.5-3 16 0" stroke="#664500" stroke-width="2" fill="none" stroke-linecap="round"/>';
|
||||||
|
const mouthOpen = '<ellipse cx="18" cy="22" rx="5" ry="4" fill="#664500"/>';
|
||||||
|
const mouthLine = '<path d="M12 22h12" stroke="#664500" stroke-width="2" stroke-linecap="round"/>';
|
||||||
|
|
||||||
|
const parts = ['<svg xmlns="http://www.w3.org/2000/svg" style="display:none">'];
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
if (id.includes('grinning') || id.includes('smile') || id.includes('blush') || id.includes('slight')) {
|
||||||
|
parts.push(face(id, mouthSmile));
|
||||||
|
} else if (id.includes('laugh') || id.includes('rofl') || id.includes('party')) {
|
||||||
|
parts.push(face(id, mouthLaugh));
|
||||||
|
} else if (id.includes('cry') || id.includes('sob') || id.includes('tired')) {
|
||||||
|
parts.push(face(id, mouthSad));
|
||||||
|
} else if (id.includes('shock') || id.includes('fear') || id.includes('rage') || id.includes('angry') || id.includes('sick') || id.includes('mask')) {
|
||||||
|
parts.push(face(id, mouthOpen));
|
||||||
|
} else if (id.includes('heart')) {
|
||||||
|
const colors = {
|
||||||
|
red: '#EF4444',
|
||||||
|
orange: '#F97316',
|
||||||
|
yellow: '#EAB308',
|
||||||
|
green: '#22C55E',
|
||||||
|
blue: '#3B82F6',
|
||||||
|
purple: '#A855F7',
|
||||||
|
black: '#111827',
|
||||||
|
white: '#F9FAFB',
|
||||||
|
broken: '#9CA3AF',
|
||||||
|
sparkling: '#EC4899',
|
||||||
|
two: '#F43F5E',
|
||||||
|
revolving: '#FB7185'
|
||||||
|
};
|
||||||
|
const key = Object.keys(colors).find((item) => id.includes(item)) || 'red';
|
||||||
|
parts.push(heart(id, colors[key]));
|
||||||
|
} else if (id.includes('thumbs-up')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="10" y="8" width="8" height="18" rx="4" fill="#FBBF24" transform="rotate(-20 14 17)"/><rect x="18" y="14" width="10" height="8" rx="3" fill="#FBBF24"/></symbol>`);
|
||||||
|
} else if (id.includes('thumbs-down')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="10" y="10" width="8" height="18" rx="4" fill="#FBBF24" transform="rotate(20 14 19)"/><rect x="18" y="14" width="10" height="8" rx="3" fill="#FBBF24"/></symbol>`);
|
||||||
|
} else if (id.includes('fire')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><path d="M18 4c2 6 6 8 6 14a6 6 0 1 1-12 0c0-4 3-6 6-14z" fill="#F97316"/><path d="M18 14c1 3 3 4 3 7a3 3 0 1 1-6 0c0-2 1-3 3-7z" fill="#FACC15"/></symbol>`);
|
||||||
|
} else if (id.includes('star')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><polygon points="18,3 22,14 34,14 24,21 28,33 18,26 8,33 12,21 2,14 14,14" fill="#FACC15"/></symbol>`);
|
||||||
|
} else if (id.includes('check')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><circle cx="18" cy="18" r="16" fill="#22C55E"/><path d="M11 18l4 4 10-10" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round"/></symbol>`);
|
||||||
|
} else if (id.includes('cross')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><circle cx="18" cy="18" r="16" fill="#EF4444"/><path d="M13 13l10 10M23 13l-10 10" stroke="#fff" stroke-width="3" stroke-linecap="round"/></symbol>`);
|
||||||
|
} else if (id.includes('lock')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="10" y="16" width="16" height="14" rx="3" fill="#64748B"/><path d="M13 16v-3a5 5 0 0 1 10 0v3" stroke="#64748B" stroke-width="3" fill="none"/></symbol>`);
|
||||||
|
} else if (id.includes('dog')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><circle cx="18" cy="20" r="10" fill="#D97706"/><circle cx="10" cy="12" r="4" fill="#92400E"/><circle cx="26" cy="12" r="4" fill="#92400E"/></symbol>`);
|
||||||
|
} else if (id.includes('cat')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><circle cx="18" cy="20" r="10" fill="#FB923C"/><polygon points="10,12 14,18 6,18" fill="#FB923C"/><polygon points="26,12 30,18 22,18" fill="#FB923C"/></symbol>`);
|
||||||
|
} else if (id.includes('pizza')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><path d="M6 28 L30 28 L18 6 Z" fill="#FACC15"/><circle cx="16" cy="22" r="2" fill="#EF4444"/></symbol>`);
|
||||||
|
} else if (id.includes('coffee')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="10" y="12" width="14" height="12" rx="3" fill="#78350F"/><path d="M24 15h3a3 3 0 0 1 0 6h-3" stroke="#78350F" stroke-width="2" fill="none"/></symbol>`);
|
||||||
|
} else if (id.includes('car')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="6" y="16" width="24" height="10" rx="4" fill="#3B82F6"/><circle cx="12" cy="26" r="3" fill="#111"/><circle cx="24" cy="26" r="3" fill="#111"/></symbol>`);
|
||||||
|
} else if (id.includes('airplane')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><path d="M6 18 L30 10 L22 18 L30 26 Z" fill="#60A5FA"/></symbol>`);
|
||||||
|
} else if (id.includes('rocket')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><path d="M18 4c4 8 4 12 4 18H14c0-6 0-10 4-18z" fill="#CBD5E1"/><circle cx="18" cy="16" r="3" fill="#38BDF8"/></symbol>`);
|
||||||
|
} else if (id.includes('bulb')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><circle cx="18" cy="16" r="9" fill="#FDE047"/><rect x="14" y="24" width="8" height="4" rx="2" fill="#94A3B8"/></symbol>`);
|
||||||
|
} else if (id.includes('phone')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="12" y="6" width="12" height="24" rx="3" fill="#334155"/></symbol>`);
|
||||||
|
} else if (id.includes('100')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="4" y="8" width="28" height="20" rx="4" fill="#EF4444"/><text x="18" y="22" text-anchor="middle" font-size="10" fill="#fff" font-family="Arial,sans-serif">100</text></symbol>`);
|
||||||
|
} else if (id.includes('wink')) {
|
||||||
|
parts.push(face(id, mouthSmile, '<path d="M22 15h4" stroke="#664500" stroke-width="2" stroke-linecap="round"/>'));
|
||||||
|
} else if (id.includes('thinking')) {
|
||||||
|
parts.push(face(id, mouthLine, '<path d="M24 8c3 0 3 4 0 4" stroke="#664500" stroke-width="2" fill="none"/>'));
|
||||||
|
} else if (id.includes('sleeping')) {
|
||||||
|
parts.push(face(id, mouthLine, '<text x="24" y="10" font-size="8" fill="#3B82F6" font-family="Arial">Zzz</text>'));
|
||||||
|
} else if (id.includes('cool')) {
|
||||||
|
parts.push(face(id, mouthSmile, '<rect x="8" y="13" width="20" height="5" rx="2" fill="#111" opacity=".85"/>'));
|
||||||
|
} else if (id.includes('kiss')) {
|
||||||
|
parts.push(face(id, '', '<circle cx="18" cy="22" r="4" fill="#EC4899"/>'));
|
||||||
|
} else if (id.includes('tongue')) {
|
||||||
|
parts.push(face(id, '', '<ellipse cx="18" cy="21" rx="4" ry="3" fill="#664500"/><ellipse cx="18" cy="26" rx="3" ry="4" fill="#F472B6"/>'));
|
||||||
|
} else if (id.includes('wave')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><path d="M8 20c0-8 8-8 8 0s8-8 8 0" stroke="#FBBF24" stroke-width="5" fill="none" stroke-linecap="round"/></symbol>`);
|
||||||
|
} else if (id.includes('clap')) {
|
||||||
|
parts.push(`<symbol id="${id}" viewBox="0 0 36 36"><rect x="8" y="10" width="7" height="16" rx="3" fill="#FBBF24"/><rect x="21" y="10" width="7" height="16" rx="3" fill="#FBBF24"/></symbol>`);
|
||||||
|
} else {
|
||||||
|
const label = id.replace('e-', '').slice(0, 3).toUpperCase();
|
||||||
|
const hue = Math.abs(id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)) % 360;
|
||||||
|
parts.push(circle(id, `hsl(${hue} 70% 55%)`, label));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push('</svg>');
|
||||||
|
const out = path.join(__dirname, '../public/emojis/lendry-emojis.svg');
|
||||||
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||||
|
fs.writeFileSync(out, parts.join(''));
|
||||||
|
console.log(`Generated ${ids.length} emoji symbols`);
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"prisma:validate": "prisma validate --schema prisma/schema.prisma",
|
"prisma:validate": "prisma validate --schema prisma/schema.prisma",
|
||||||
"prisma:format": "prisma format --schema prisma/schema.prisma",
|
"prisma:format": "prisma format --schema prisma/schema.prisma",
|
||||||
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
|
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
|
||||||
"prisma:push": "prisma db push --schema prisma/schema.prisma",
|
"prisma:push": "prisma db push --schema prisma/schema.prisma --accept-data-loss",
|
||||||
"proto:generate": "node scripts/generate-proto-types.mjs"
|
"proto:generate": "node scripts/generate-proto-types.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ model User {
|
|||||||
birthDate DateTime?
|
birthDate DateTime?
|
||||||
gender String?
|
gender String?
|
||||||
isSuperAdmin Boolean @default(false)
|
isSuperAdmin Boolean @default(false)
|
||||||
|
isSystemAccount Boolean @default(false)
|
||||||
|
linkedBotId String? @unique
|
||||||
|
e2ePublicKey String?
|
||||||
isVerified Boolean @default(false)
|
isVerified Boolean @default(false)
|
||||||
verificationIcon String?
|
verificationIcon String?
|
||||||
verifiedAt DateTime?
|
verifiedAt DateTime?
|
||||||
@@ -80,6 +83,8 @@ model User {
|
|||||||
chatPollVotes ChatPollVote[]
|
chatPollVotes ChatPollVote[]
|
||||||
totpSecret UserTotpSecret?
|
totpSecret UserTotpSecret?
|
||||||
createdOAuthClients OAuthClient[] @relation("OAuthClientCreator")
|
createdOAuthClients OAuthClient[] @relation("OAuthClientCreator")
|
||||||
|
ownedBots Bot[] @relation("BotOwner")
|
||||||
|
linkedBot Bot? @relation("BotSystemUser", fields: [linkedBotId], references: [id], onDelete: SetNull)
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserTotpSecret {
|
model UserTotpSecret {
|
||||||
@@ -391,6 +396,9 @@ model ChatRoom {
|
|||||||
groupId String
|
groupId String
|
||||||
type String
|
type String
|
||||||
name String
|
name String
|
||||||
|
peerUserId String?
|
||||||
|
botUsername String?
|
||||||
|
isE2E Boolean @default(false)
|
||||||
avatarStorageKey String?
|
avatarStorageKey String?
|
||||||
hasAvatar Boolean @default(false)
|
hasAvatar Boolean @default(false)
|
||||||
createdById String?
|
createdById String?
|
||||||
@@ -401,6 +409,7 @@ model ChatRoom {
|
|||||||
messages ChatMessage[]
|
messages ChatMessage[]
|
||||||
|
|
||||||
@@index([groupId, type])
|
@@index([groupId, type])
|
||||||
|
@@index([groupId, type, peerUserId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ChatRoomMember {
|
model ChatRoomMember {
|
||||||
@@ -418,18 +427,19 @@ model ChatRoomMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ChatMessage {
|
model ChatMessage {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
roomId String
|
roomId String
|
||||||
senderId String
|
senderId String
|
||||||
type String
|
type String
|
||||||
content String?
|
content String?
|
||||||
replyToId String?
|
isEncrypted Boolean @default(false)
|
||||||
metadata Json?
|
replyToId String?
|
||||||
storageKey String?
|
metadata Json?
|
||||||
mimeType String?
|
storageKey String?
|
||||||
createdAt DateTime @default(now())
|
mimeType String?
|
||||||
editedAt DateTime?
|
createdAt DateTime @default(now())
|
||||||
deletedAt DateTime?
|
editedAt DateTime?
|
||||||
|
deletedAt DateTime?
|
||||||
room ChatRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
room ChatRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||||
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
||||||
poll ChatPoll?
|
poll ChatPoll?
|
||||||
@@ -516,3 +526,115 @@ model SocialProvider {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Bot {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
username String @unique
|
||||||
|
tokenHash String @unique
|
||||||
|
tokenPrefix String
|
||||||
|
ownerId String
|
||||||
|
description String?
|
||||||
|
aboutText String?
|
||||||
|
botPicUrl String?
|
||||||
|
menuButton Json?
|
||||||
|
webAppUrl String?
|
||||||
|
webhookUrl String?
|
||||||
|
webhookSecretToken String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
isSystemBot Boolean @default(false)
|
||||||
|
nextMessageId Int @default(1)
|
||||||
|
nextUpdateId Int @default(1)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
owner User @relation("BotOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
linkedSystemUser User? @relation("BotSystemUser")
|
||||||
|
chats BotChat[]
|
||||||
|
messages BotMessage[]
|
||||||
|
inboundMessages BotInboundMessage[]
|
||||||
|
updates BotUpdate[]
|
||||||
|
chatMenuButtons ChatMenuButton[]
|
||||||
|
|
||||||
|
@@index([ownerId])
|
||||||
|
@@index([tokenHash])
|
||||||
|
@@index([isActive])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BotChat {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
botId String
|
||||||
|
recipientUserId String?
|
||||||
|
telegramChatId BigInt
|
||||||
|
chatType String @default("private")
|
||||||
|
nextInboundMessageId Int @default(1)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
|
||||||
|
messages BotMessage[]
|
||||||
|
inboundMessages BotInboundMessage[]
|
||||||
|
menuButton ChatMenuButton?
|
||||||
|
|
||||||
|
@@unique([botId, recipientUserId])
|
||||||
|
@@unique([botId, telegramChatId])
|
||||||
|
@@index([botId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ChatMenuButton {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
botId String
|
||||||
|
botChatId String @unique
|
||||||
|
menuButton Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
|
||||||
|
botChat BotChat @relation(fields: [botChatId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([botId, botChatId])
|
||||||
|
@@index([botId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BotMessage {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
botId String
|
||||||
|
botChatId String
|
||||||
|
telegramMsgId Int
|
||||||
|
messageType String @default("text")
|
||||||
|
text String?
|
||||||
|
mediaUrl String?
|
||||||
|
payload Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
editedAt DateTime?
|
||||||
|
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
|
||||||
|
botChat BotChat @relation(fields: [botChatId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([botId, telegramMsgId])
|
||||||
|
@@index([botChatId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BotInboundMessage {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
botId String
|
||||||
|
botChatId String
|
||||||
|
senderUserId String
|
||||||
|
telegramMsgId Int
|
||||||
|
text String?
|
||||||
|
payload Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
|
||||||
|
botChat BotChat @relation(fields: [botChatId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([botChatId, telegramMsgId])
|
||||||
|
@@index([botId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BotUpdate {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
botId String
|
||||||
|
updateId Int
|
||||||
|
payload Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([botId, updateId])
|
||||||
|
@@index([botId, updateId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,20 @@ import { MessagingService } from './infra/messaging.service';
|
|||||||
import { SmsService } from './infra/sms.service';
|
import { SmsService } from './infra/sms.service';
|
||||||
import { TotpService } from './domain/totp.service';
|
import { TotpService } from './domain/totp.service';
|
||||||
import { MaintenanceSchedulerService } from './domain/maintenance-scheduler.service';
|
import { MaintenanceSchedulerService } from './domain/maintenance-scheduler.service';
|
||||||
|
import { BotFatherService } from './domain/bot/bot-father.service';
|
||||||
|
import { BotApiService } from './domain/bot/bot-api.service';
|
||||||
|
import { BotRateLimitService } from './domain/bot/bot-rate-limit.service';
|
||||||
|
import { WebAppCryptoService } from './domain/bot/webapp-crypto.service';
|
||||||
|
import { TelegramSerializerService } from './domain/bot/telegram-serializer.service';
|
||||||
|
import { BotUpdateQueueService } from './domain/bot/bot-update-queue.service';
|
||||||
|
import { BotWebhookDispatcherService } from './domain/bot/bot-webhook-dispatcher.service';
|
||||||
|
import { BotInboundPublisherService } from './domain/bot/bot-inbound-publisher.service';
|
||||||
|
import { BotDeliveryService } from './domain/bot/bot-delivery.service';
|
||||||
|
import { BotMessageConsumerService } from './domain/bot/bot-message-consumer.service';
|
||||||
|
import { BotInboundService } from './domain/bot/bot-inbound.service';
|
||||||
|
import { BotCallbackRegistryService } from './domain/bot/bot-callback-registry.service';
|
||||||
|
import { BotFatherSeedService } from './domain/bot/bot-father-seed.service';
|
||||||
|
import { BotFatherAssistantService } from './domain/bot/bot-father-assistant.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -74,7 +88,21 @@ import { MaintenanceSchedulerService } from './domain/maintenance-scheduler.serv
|
|||||||
LdapClientService,
|
LdapClientService,
|
||||||
MessagingService,
|
MessagingService,
|
||||||
SmsService,
|
SmsService,
|
||||||
MaintenanceSchedulerService
|
MaintenanceSchedulerService,
|
||||||
|
BotFatherService,
|
||||||
|
BotApiService,
|
||||||
|
BotRateLimitService,
|
||||||
|
WebAppCryptoService,
|
||||||
|
TelegramSerializerService,
|
||||||
|
BotUpdateQueueService,
|
||||||
|
BotWebhookDispatcherService,
|
||||||
|
BotInboundPublisherService,
|
||||||
|
BotDeliveryService,
|
||||||
|
BotMessageConsumerService,
|
||||||
|
BotInboundService,
|
||||||
|
BotCallbackRegistryService,
|
||||||
|
BotFatherSeedService,
|
||||||
|
BotFatherAssistantService
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface UserAccessContext {
|
|||||||
canManageSettings: boolean;
|
canManageSettings: boolean;
|
||||||
canViewUserDocuments: boolean;
|
canViewUserDocuments: boolean;
|
||||||
canVerifyUsers: boolean;
|
canVerifyUsers: boolean;
|
||||||
|
canManageBots: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAnyPermission(permissions: string[], ...slugs: string[]) {
|
function hasAnyPermission(permissions: string[], ...slugs: string[]) {
|
||||||
@@ -42,7 +43,8 @@ export class AccessService {
|
|||||||
'settings.manage',
|
'settings.manage',
|
||||||
'rbac.manage',
|
'rbac.manage',
|
||||||
'documents.view_others',
|
'documents.view_others',
|
||||||
'users.verify'
|
'users.verify',
|
||||||
|
'bots.manage.all'
|
||||||
],
|
],
|
||||||
canAccessAdmin: true,
|
canAccessAdmin: true,
|
||||||
canManageRoles: true,
|
canManageRoles: true,
|
||||||
@@ -54,7 +56,8 @@ export class AccessService {
|
|||||||
canViewUsers: true,
|
canViewUsers: true,
|
||||||
canManageSettings: true,
|
canManageSettings: true,
|
||||||
canViewUserDocuments: true,
|
canViewUserDocuments: true,
|
||||||
canVerifyUsers: true
|
canVerifyUsers: true,
|
||||||
|
canManageBots: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +105,8 @@ export class AccessService {
|
|||||||
canViewUsers,
|
canViewUsers,
|
||||||
canManageSettings: permissions.includes('settings.manage'),
|
canManageSettings: permissions.includes('settings.manage'),
|
||||||
canViewUserDocuments: permissions.includes('documents.view_others'),
|
canViewUserDocuments: permissions.includes('documents.view_others'),
|
||||||
canVerifyUsers: permissions.includes('users.verify')
|
canVerifyUsers: permissions.includes('users.verify'),
|
||||||
|
canManageBots: permissions.includes('bots.manage.all')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ const PERMISSIONS = [
|
|||||||
{ slug: 'users.read', name: 'Просмотр пользователей (устар.)', description: 'Устаревшее право, заменено на users.view' },
|
{ slug: 'users.read', name: 'Просмотр пользователей (устар.)', description: 'Устаревшее право, заменено на users.view' },
|
||||||
{ slug: 'settings.manage', name: 'Управление настройками', description: 'Изменение глобальных SystemSetting' },
|
{ slug: 'settings.manage', name: 'Управление настройками', description: 'Изменение глобальных SystemSetting' },
|
||||||
{ slug: 'rbac.manage', name: 'Управление ролями и правами', description: 'Назначение ролей, прав пользователям и редактирование ролей' },
|
{ slug: 'rbac.manage', name: 'Управление ролями и правами', description: 'Назначение ролей, прав пользователям и редактирование ролей' },
|
||||||
{ slug: 'documents.view_others', name: 'Просмотр документов пользователей', description: 'Просмотр документов и фото других пользователей' }
|
{ slug: 'documents.view_others', name: 'Просмотр документов пользователей', description: 'Просмотр документов и фото других пользователей' },
|
||||||
|
{ slug: 'bots.manage.all', name: 'Управление всеми ботами', description: 'Просмотр, блокировка и администрирование всех Telegram-ботов' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const ROLES = [
|
const ROLES = [
|
||||||
@@ -33,7 +34,7 @@ const ROLES = [
|
|||||||
slug: 'admin',
|
slug: 'admin',
|
||||||
name: 'Администратор',
|
name: 'Администратор',
|
||||||
description: 'Полный доступ к админ-панели',
|
description: 'Полный доступ к админ-панели',
|
||||||
permissions: ['admin.access', 'oauth.manage.all', 'oauth.view.all', 'users.manage.all', 'users.view.all', 'users.verify', 'settings.manage', 'rbac.manage'],
|
permissions: ['admin.access', 'oauth.manage.all', 'oauth.view.all', 'users.manage.all', 'users.view.all', 'users.verify', 'settings.manage', 'rbac.manage', 'bots.manage.all'],
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
isDefault: false
|
isDefault: false
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import { ChatService } from './chat.service';
|
|||||||
import { PresenceService } from './presence.service';
|
import { PresenceService } from './presence.service';
|
||||||
import { TotpService } from './totp.service';
|
import { TotpService } from './totp.service';
|
||||||
import { MessagingService } from '../infra/messaging.service';
|
import { MessagingService } from '../infra/messaging.service';
|
||||||
|
import { BotFatherService } from './bot/bot-father.service';
|
||||||
|
import { BotApiService } from './bot/bot-api.service';
|
||||||
|
import { BotInboundService } from './bot/bot-inbound.service';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
@UseFilters(new GrpcExceptionFilter())
|
@UseFilters(new GrpcExceptionFilter())
|
||||||
@@ -47,7 +50,10 @@ export class AuthGrpcController {
|
|||||||
private readonly chat: ChatService,
|
private readonly chat: ChatService,
|
||||||
private readonly presence: PresenceService,
|
private readonly presence: PresenceService,
|
||||||
private readonly messaging: MessagingService,
|
private readonly messaging: MessagingService,
|
||||||
private readonly totp: TotpService
|
private readonly totp: TotpService,
|
||||||
|
private readonly botFather: BotFatherService,
|
||||||
|
private readonly botApi: BotApiService,
|
||||||
|
private readonly botInbound: BotInboundService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@GrpcMethod('AuthService', 'Register')
|
@GrpcMethod('AuthService', 'Register')
|
||||||
@@ -634,6 +640,16 @@ export class AuthGrpcController {
|
|||||||
return this.profile.getAccountDeletionStatus(command.userId);
|
return this.profile.getAccountDeletionStatus(command.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('ProfileService', 'SetE2EPublicKey')
|
||||||
|
setE2EPublicKey(command: { userId: string; publicKey: string }) {
|
||||||
|
return this.profile.setE2EPublicKey(command.userId, command.publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('ProfileService', 'GetE2EPublicKey')
|
||||||
|
getE2EPublicKey(command: { userId: string }) {
|
||||||
|
return this.profile.getE2EPublicKey(command.userId);
|
||||||
|
}
|
||||||
|
|
||||||
@GrpcMethod('DocumentsService', 'CreateDocument')
|
@GrpcMethod('DocumentsService', 'CreateDocument')
|
||||||
createDocument(command: { userId: string; type: string; number: string; issuedAt?: string; expiresAt?: string; metadataJson?: string }) {
|
createDocument(command: { userId: string; type: string; number: string; issuedAt?: string; expiresAt?: string; metadataJson?: string }) {
|
||||||
return this.documents.create(command);
|
return this.documents.create(command);
|
||||||
@@ -875,6 +891,20 @@ export class AuthGrpcController {
|
|||||||
return this.presence.getFamilyPresence(command.requesterId, command.groupId);
|
return this.presence.getFamilyPresence(command.requesterId, command.groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('FamilyService', 'AddBotFatherToFamily')
|
||||||
|
async addBotFatherToFamily(command: { requesterId: string; groupId: string }) {
|
||||||
|
const member = await this.family.addBotFatherToFamily(command.requesterId, command.groupId);
|
||||||
|
await this.chat.syncFamilyChats(command.groupId);
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('FamilyService', 'AddBotToFamily')
|
||||||
|
async addBotToFamily(command: { requesterId: string; groupId: string; botId: string }) {
|
||||||
|
const member = await this.family.addBotToFamily(command.requesterId, command.groupId, command.botId);
|
||||||
|
await this.chat.syncFamilyChats(command.groupId);
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
@GrpcMethod('NotificationsService', 'ListNotifications')
|
@GrpcMethod('NotificationsService', 'ListNotifications')
|
||||||
listNotifications(command: { userId: string; limit?: number; unreadOnly?: boolean }) {
|
listNotifications(command: { userId: string; limit?: number; unreadOnly?: boolean }) {
|
||||||
return this.notifications.list(command.userId, command.limit, command.unreadOnly);
|
return this.notifications.list(command.userId, command.limit, command.unreadOnly);
|
||||||
@@ -915,6 +945,11 @@ export class AuthGrpcController {
|
|||||||
return this.chat.createRoom(command.userId, command.groupId, command.name, command.memberUserIds ?? []);
|
return this.chat.createRoom(command.userId, command.groupId, command.name, command.memberUserIds ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('ChatService', 'CreateE2ERoom')
|
||||||
|
createE2EChatRoom(command: { userId: string; groupId: string; peerUserId: string }) {
|
||||||
|
return this.chat.createE2ERoom(command.userId, command.groupId, command.peerUserId);
|
||||||
|
}
|
||||||
|
|
||||||
@GrpcMethod('ChatService', 'UpdateRoomSettings')
|
@GrpcMethod('ChatService', 'UpdateRoomSettings')
|
||||||
updateChatRoomSettings(command: { userId: string; roomId: string; name?: string; notificationsMuted?: boolean }) {
|
updateChatRoomSettings(command: { userId: string; roomId: string; name?: string; notificationsMuted?: boolean }) {
|
||||||
return this.chat.updateRoomSettings(command.userId, command.roomId, command.name, command.notificationsMuted);
|
return this.chat.updateRoomSettings(command.userId, command.roomId, command.name, command.notificationsMuted);
|
||||||
@@ -946,6 +981,7 @@ export class AuthGrpcController {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
metadataJson?: string;
|
metadataJson?: string;
|
||||||
poll?: { question: string; options: string[]; allowsMultiple?: boolean; isAnonymous?: boolean };
|
poll?: { question: string; options: string[]; allowsMultiple?: boolean; isAnonymous?: boolean };
|
||||||
|
isEncrypted?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return this.chat.sendMessage(
|
return this.chat.sendMessage(
|
||||||
command.userId,
|
command.userId,
|
||||||
@@ -956,7 +992,8 @@ export class AuthGrpcController {
|
|||||||
command.storageKey,
|
command.storageKey,
|
||||||
command.mimeType,
|
command.mimeType,
|
||||||
command.metadataJson,
|
command.metadataJson,
|
||||||
command.poll
|
command.poll,
|
||||||
|
command.isEncrypted
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -970,6 +1007,11 @@ export class AuthGrpcController {
|
|||||||
return this.chat.setRoomNotificationsMuted(command.userId, command.roomId, command.muted);
|
return this.chat.setRoomNotificationsMuted(command.userId, command.roomId, command.muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('ChatService', 'DeleteRoom')
|
||||||
|
deleteChatRoom(command: { userId: string; roomId: string }) {
|
||||||
|
return this.chat.deleteRoom(command.userId, command.roomId);
|
||||||
|
}
|
||||||
|
|
||||||
@GrpcMethod('ChatService', 'EditMessage')
|
@GrpcMethod('ChatService', 'EditMessage')
|
||||||
editChatMessage(command: { userId: string; messageId: string; content: string }) {
|
editChatMessage(command: { userId: string; messageId: string; content: string }) {
|
||||||
return this.chat.editMessage(command.userId, command.messageId, command.content);
|
return this.chat.editMessage(command.userId, command.messageId, command.content);
|
||||||
@@ -980,11 +1022,145 @@ export class AuthGrpcController {
|
|||||||
return this.chat.deleteMessage(command.userId, command.messageId);
|
return this.chat.deleteMessage(command.userId, command.messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('ChatService', 'ToggleMessageReaction')
|
||||||
|
toggleMessageReaction(command: { userId: string; messageId: string; emoji: string }) {
|
||||||
|
return this.chat.toggleMessageReaction(command.userId, command.messageId, command.emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('ChatService', 'ForwardMessages')
|
||||||
|
forwardMessages(command: { userId: string; targetRoomId: string; messageIds: string[] }) {
|
||||||
|
return this.chat.forwardMessages(command.userId, command.targetRoomId, command.messageIds ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
@GrpcMethod('ChatService', 'MarkRoomRead')
|
@GrpcMethod('ChatService', 'MarkRoomRead')
|
||||||
markChatRoomRead(command: { userId: string; roomId: string; lastMessageId?: string }) {
|
markChatRoomRead(command: { userId: string; roomId: string; lastMessageId?: string }) {
|
||||||
return this.chat.markRoomRead(command.userId, command.roomId, command.lastMessageId);
|
return this.chat.markRoomRead(command.userId, command.roomId, command.lastMessageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('ChatService', 'ReportTyping')
|
||||||
|
reportTyping(command: { userId: string; roomId: string }) {
|
||||||
|
return this.chat.reportTyping(command.userId, command.roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'CreateBot')
|
||||||
|
createBot(command: { ownerId: string; name: string; username: string }) {
|
||||||
|
return this.botFather.createBot(command.ownerId, command.name, command.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'RevokeBotToken')
|
||||||
|
revokeBotToken(command: { requesterId: string; botId: string; isSuperAdmin?: boolean }) {
|
||||||
|
return this.botFather.revokeToken(command.requesterId, command.botId, Boolean(command.isSuperAdmin));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'SetBotWebApp')
|
||||||
|
setBotWebApp(command: { requesterId: string; botId: string; webAppUrl?: string; isSuperAdmin?: boolean }) {
|
||||||
|
return this.botFather.setWebApp(command.requesterId, command.botId, command.webAppUrl, Boolean(command.isSuperAdmin));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'ListMyBots')
|
||||||
|
listMyBots(command: { ownerId: string }) {
|
||||||
|
return this.botFather.listMyBots(command.ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'GetBot')
|
||||||
|
getBot(command: { requesterId: string; botId: string; isSuperAdmin?: boolean }) {
|
||||||
|
return this.botFather.getBot(command.requesterId, command.botId, Boolean(command.isSuperAdmin));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'UpdateBot')
|
||||||
|
updateBot(command: { requesterId: string; botId: string; name?: string; username?: string; isSuperAdmin?: boolean }) {
|
||||||
|
return this.botFather.updateBot(command.requesterId, command.botId, command, Boolean(command.isSuperAdmin));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'UpdateBotProfile')
|
||||||
|
updateBotProfile(command: {
|
||||||
|
requesterId: string;
|
||||||
|
botId: string;
|
||||||
|
description?: string;
|
||||||
|
aboutText?: string;
|
||||||
|
botPicUrl?: string;
|
||||||
|
menuButtonJson?: string;
|
||||||
|
menuButtonUrl?: string;
|
||||||
|
menuButtonText?: string;
|
||||||
|
isSuperAdmin?: boolean;
|
||||||
|
}) {
|
||||||
|
const patch: {
|
||||||
|
description?: string | null;
|
||||||
|
aboutText?: string | null;
|
||||||
|
botPicUrl?: string | null;
|
||||||
|
menuButton?: unknown;
|
||||||
|
} = {
|
||||||
|
description: command.description,
|
||||||
|
aboutText: command.aboutText,
|
||||||
|
botPicUrl: command.botPicUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
if (command.menuButtonJson !== undefined) {
|
||||||
|
patch.menuButton = command.menuButtonJson.trim() ? command.menuButtonJson : { type: 'default' };
|
||||||
|
} else if (command.menuButtonUrl !== undefined) {
|
||||||
|
const url = command.menuButtonUrl.trim();
|
||||||
|
patch.menuButton = url
|
||||||
|
? command.menuButtonText?.trim()
|
||||||
|
? `${url}|${command.menuButtonText.trim()}`
|
||||||
|
: url
|
||||||
|
: { type: 'default' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.botFather.updateBotProfile(command.requesterId, command.botId, patch, Boolean(command.isSuperAdmin));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'DeleteBot')
|
||||||
|
deleteBot(command: { requesterId: string; botId: string; isSuperAdmin?: boolean }) {
|
||||||
|
return this.botFather.deleteBot(command.requesterId, command.botId, Boolean(command.isSuperAdmin));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'ListAllBots')
|
||||||
|
listAllBots(command: { requesterId: string; isSuperAdmin?: boolean; search?: string; page?: number; limit?: number }) {
|
||||||
|
return this.botFather.listAllBots(command.requesterId, Boolean(command.isSuperAdmin), command.search, command.page, command.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'SetBotActive')
|
||||||
|
setBotActive(command: { requesterId: string; botId: string; isActive: boolean; isSuperAdmin?: boolean }) {
|
||||||
|
return this.botFather.setBotActive(command.requesterId, command.botId, command.isActive, Boolean(command.isSuperAdmin));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'GetBotMetrics')
|
||||||
|
getBotMetrics(command: { requesterId: string; isSuperAdmin?: boolean }) {
|
||||||
|
return this.botFather.getBotMetrics(command.requesterId, Boolean(command.isSuperAdmin));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'ValidateBotToken')
|
||||||
|
async validateBotToken(command: { token: string }) {
|
||||||
|
const bot = await this.botFather.validateBotToken(command.token);
|
||||||
|
return bot ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'ExecuteBotMethod')
|
||||||
|
executeBotMethod(command: { token: string; method: string; payloadJson: string }) {
|
||||||
|
const payload = command.payloadJson ? (JSON.parse(command.payloadJson) as Record<string, unknown>) : {};
|
||||||
|
return this.botApi.executeMethod(command.token, command.method, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'ValidateWebAppInitData')
|
||||||
|
validateWebAppInitData(command: { initData: string; botToken: string }) {
|
||||||
|
return this.botApi.validateWebAppInitData(command.initData, command.botToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'SubmitBotInboundMessage')
|
||||||
|
submitBotInboundMessage(command: { senderUserId: string; botRef: string; text: string }) {
|
||||||
|
return this.botInbound.submitUserMessage(command.senderUserId, command.botRef, command.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'SubmitBotCallbackQuery')
|
||||||
|
submitBotCallbackQuery(command: { senderUserId: string; botRef: string; messageId: number; callbackData: string }) {
|
||||||
|
return this.botInbound.submitCallbackQuery(command.senderUserId, command.botRef, command.messageId, command.callbackData);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GrpcMethod('BotService', 'ListBotChatMessages')
|
||||||
|
listBotChatMessages(command: { userId: string; botRef: string }) {
|
||||||
|
return this.botApi.listBotChatMessages(command.userId, command.botRef);
|
||||||
|
}
|
||||||
|
|
||||||
private toSessionResponse(session: { id: string; userId: string; deviceId: string | null; pinVerified: boolean; status: string; ipAddress: string | null; userAgent: string | null; expiresAt: Date; createdAt: Date; updatedAt: Date }) {
|
private toSessionResponse(session: { id: string; userId: string; deviceId: string | null; pinVerified: boolean; status: string; ipAddress: string | null; userAgent: string | null; expiresAt: Date; createdAt: Date; updatedAt: Date }) {
|
||||||
return {
|
return {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import { canonicalPhoneE164, phoneLookupVariants } from '../infra/phone.util';
|
|||||||
import { TotpService } from './totp.service';
|
import { TotpService } from './totp.service';
|
||||||
import { RbacService } from './rbac.service';
|
import { RbacService } from './rbac.service';
|
||||||
import { DEFAULT_VERIFICATION_ICON, resolveVerificationIcon } from './verification.constants';
|
import { DEFAULT_VERIFICATION_ICON, resolveVerificationIcon } from './verification.constants';
|
||||||
|
import {
|
||||||
|
assertHumanAccount,
|
||||||
|
assertNotReservedUsername,
|
||||||
|
HUMAN_USER_WHERE
|
||||||
|
} from './system-account.util';
|
||||||
|
|
||||||
const FIRST_ADMIN_LOCK = 'locks:first-super-admin';
|
const FIRST_ADMIN_LOCK = 'locks:first-super-admin';
|
||||||
const TOTP_LOGIN_CHALLENGE_TTL_SECONDS = 5 * 60;
|
const TOTP_LOGIN_CHALLENGE_TTL_SECONDS = 5 * 60;
|
||||||
@@ -44,6 +49,8 @@ export class AuthService {
|
|||||||
throw new BadRequestException('Укажите почту или телефон');
|
throw new BadRequestException('Укажите почту или телефон');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertNotReservedUsername(command.username);
|
||||||
|
|
||||||
const passwordHash = command.password ? await bcrypt.hash(command.password, 12) : null;
|
const passwordHash = command.password ? await bcrypt.hash(command.password, 12) : null;
|
||||||
const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000);
|
const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000);
|
||||||
|
|
||||||
@@ -86,7 +93,8 @@ export class AuthService {
|
|||||||
where: {
|
where: {
|
||||||
OR: [{ email: command.login }, { phone: command.login }, { username: command.login }],
|
OR: [{ email: command.login }, { phone: command.login }, { username: command.login }],
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
status: UserStatus.ACTIVE
|
status: UserStatus.ACTIVE,
|
||||||
|
...HUMAN_USER_WHERE
|
||||||
},
|
},
|
||||||
include: { pinCode: true }
|
include: { pinCode: true }
|
||||||
});
|
});
|
||||||
@@ -95,6 +103,8 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Неверный логин или пароль');
|
throw new UnauthorizedException('Неверный логин или пароль');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertHumanAccount(user, { asAuth: true });
|
||||||
|
|
||||||
if (!user.passwordHash) {
|
if (!user.passwordHash) {
|
||||||
throw new UnauthorizedException('Для этого аккаунта используйте вход по коду');
|
throw new UnauthorizedException('Для этого аккаунта используйте вход по коду');
|
||||||
}
|
}
|
||||||
@@ -115,6 +125,7 @@ export class AuthService {
|
|||||||
if (!user || user.status !== UserStatus.ACTIVE) {
|
if (!user || user.status !== UserStatus.ACTIVE) {
|
||||||
throw new UnauthorizedException('Пользователь не найден');
|
throw new UnauthorizedException('Пользователь не найден');
|
||||||
}
|
}
|
||||||
|
assertHumanAccount(user, { asAuth: true });
|
||||||
if (!(await this.totp.isEnabledForUser(user.id))) {
|
if (!(await this.totp.isEnabledForUser(user.id))) {
|
||||||
throw new BadRequestException('Для этого аккаунта не настроен аутентификатор');
|
throw new BadRequestException('Для этого аккаунта не настроен аутентификатор');
|
||||||
}
|
}
|
||||||
@@ -172,13 +183,15 @@ export class AuthService {
|
|||||||
await this.redis.client.del(attemptKey);
|
await this.redis.client.del(attemptKey);
|
||||||
|
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: { id: payload.sub, deletedAt: null, status: UserStatus.ACTIVE },
|
where: { id: payload.sub, deletedAt: null, status: UserStatus.ACTIVE, ...HUMAN_USER_WHERE },
|
||||||
include: { pinCode: true }
|
include: { pinCode: true }
|
||||||
});
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertHumanAccount(user, { asAuth: true });
|
||||||
|
|
||||||
const auth = await this.createAuthTokens(user, challenge.deviceId, challenge);
|
const auth = await this.createAuthTokens(user, challenge.deviceId, challenge);
|
||||||
await this.writeSignInEvent(user.id, true, challenge.signIn, challenge.deviceId);
|
await this.writeSignInEvent(user.id, true, challenge.signIn, challenge.deviceId);
|
||||||
return auth;
|
return auth;
|
||||||
@@ -391,6 +404,7 @@ export class AuthService {
|
|||||||
? await this.findUserByTempPasswordToken(command.tempAuthToken)
|
? await this.findUserByTempPasswordToken(command.tempAuthToken)
|
||||||
: await this.findUserByRecipient(command.login);
|
: await this.findUserByRecipient(command.login);
|
||||||
if (!user?.passwordHash || user.status !== UserStatus.ACTIVE) throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
if (!user?.passwordHash || user.status !== UserStatus.ACTIVE) throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
||||||
|
assertHumanAccount(user, { asAuth: true });
|
||||||
const matches = await bcrypt.compare(command.password, user.passwordHash);
|
const matches = await bcrypt.compare(command.password, user.passwordHash);
|
||||||
if (!matches) {
|
if (!matches) {
|
||||||
await this.writeSignInEvent(user.id, false, command, undefined, 'Неверный пароль');
|
await this.writeSignInEvent(user.id, false, command, undefined, 'Неверный пароль');
|
||||||
@@ -402,12 +416,13 @@ export class AuthService {
|
|||||||
|
|
||||||
async createQrApprovedLogin(userId: string, command: Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress' | 'userAgent'>) {
|
async createQrApprovedLogin(userId: string, command: Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress' | 'userAgent'>) {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: { id: userId, deletedAt: null, status: UserStatus.ACTIVE },
|
where: { id: userId, deletedAt: null, status: UserStatus.ACTIVE, ...HUMAN_USER_WHERE },
|
||||||
include: { pinCode: true }
|
include: { pinCode: true }
|
||||||
});
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
||||||
}
|
}
|
||||||
|
assertHumanAccount(user, { asAuth: true });
|
||||||
const device = await this.upsertDevice(user.id, {
|
const device = await this.upsertDevice(user.id, {
|
||||||
fingerprint: command.fingerprint,
|
fingerprint: command.fingerprint,
|
||||||
deviceName: command.deviceName,
|
deviceName: command.deviceName,
|
||||||
@@ -575,6 +590,8 @@ export class AuthService {
|
|||||||
deviceId: string,
|
deviceId: string,
|
||||||
options?: { skipTotp?: boolean }
|
options?: { skipTotp?: boolean }
|
||||||
): Promise<AuthTokens> {
|
): Promise<AuthTokens> {
|
||||||
|
assertHumanAccount(user, { asAuth: true });
|
||||||
|
|
||||||
if (!options?.skipTotp && (await this.totp.isEnabledForUser(user.id))) {
|
if (!options?.skipTotp && (await this.totp.isEnabledForUser(user.id))) {
|
||||||
return this.issueTotpChallenge(user, deviceCommand, signInCommand, deviceId);
|
return this.issueTotpChallenge(user, deviceCommand, signInCommand, deviceId);
|
||||||
}
|
}
|
||||||
@@ -648,6 +665,8 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async createAuthTokens(user: User & { pinCode?: { isEnabled: boolean } | null }, deviceId: string, command: Pick<LoginCommand, 'ipAddress' | 'userAgent'>): Promise<AuthTokens> {
|
private async createAuthTokens(user: User & { pinCode?: { isEnabled: boolean } | null }, deviceId: string, command: Pick<LoginCommand, 'ipAddress' | 'userAgent'>): Promise<AuthTokens> {
|
||||||
|
assertHumanAccount(user, { asAuth: true });
|
||||||
|
|
||||||
const pinVerified = !user.pinCode?.isEnabled;
|
const pinVerified = !user.pinCode?.isEnabled;
|
||||||
const refreshToken = randomBytes(48).toString('base64url');
|
const refreshToken = randomBytes(48).toString('base64url');
|
||||||
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
|
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
|
||||||
@@ -700,14 +719,14 @@ export class AuthService {
|
|||||||
private async findUserByRecipient(recipient: string) {
|
private async findUserByRecipient(recipient: string) {
|
||||||
if (recipient.includes('@')) {
|
if (recipient.includes('@')) {
|
||||||
return this.prisma.user.findFirst({
|
return this.prisma.user.findFirst({
|
||||||
where: { email: recipient.trim().toLowerCase(), deletedAt: null, status: UserStatus.ACTIVE },
|
where: { email: recipient.trim().toLowerCase(), deletedAt: null, status: UserStatus.ACTIVE, ...HUMAN_USER_WHERE },
|
||||||
include: { pinCode: true }
|
include: { pinCode: true }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const variants = phoneLookupVariants(recipient);
|
const variants = phoneLookupVariants(recipient);
|
||||||
return this.prisma.user.findFirst({
|
return this.prisma.user.findFirst({
|
||||||
where: { phone: { in: variants }, deletedAt: null, status: UserStatus.ACTIVE },
|
where: { phone: { in: variants }, deletedAt: null, status: UserStatus.ACTIVE, ...HUMAN_USER_WHERE },
|
||||||
include: { pinCode: true }
|
include: { pinCode: true }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -722,6 +741,7 @@ export class AuthService {
|
|||||||
if (!user || user.deletedAt || user.status !== UserStatus.ACTIVE) {
|
if (!user || user.deletedAt || user.status !== UserStatus.ACTIVE) {
|
||||||
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
||||||
}
|
}
|
||||||
|
assertHumanAccount(user, { asAuth: true });
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,10 +783,12 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertHumanAccount(user, { asAuth: true });
|
||||||
|
|
||||||
return this.toPublicUser(user);
|
return this.toPublicUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async toPublicUser(user: Pick<User, 'id' | 'email' | 'phone' | 'backupEmail' | 'backupPhone' | 'displayName' | 'username' | 'avatarUrl' | 'avatarStorageKey' | 'isSuperAdmin' | 'isVerified' | 'verificationIcon' | 'status' | 'passwordHash'>): Promise<PublicUser> {
|
async toPublicUser(user: Pick<User, 'id' | 'email' | 'phone' | 'backupEmail' | 'backupPhone' | 'displayName' | 'username' | 'avatarUrl' | 'avatarStorageKey' | 'isSuperAdmin' | 'isVerified' | 'verificationIcon' | 'status' | 'passwordHash' | 'isSystemAccount' | 'linkedBotId'>): Promise<PublicUser> {
|
||||||
const access = await this.access.getUserAccess(user.id, user.isSuperAdmin);
|
const access = await this.access.getUserAccess(user.id, user.isSuperAdmin);
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -795,11 +817,14 @@ export class AuthService {
|
|||||||
canManageSettings: access.canManageSettings,
|
canManageSettings: access.canManageSettings,
|
||||||
canViewUsers: access.canViewUsers,
|
canViewUsers: access.canViewUsers,
|
||||||
canViewUserDocuments: access.canViewUserDocuments,
|
canViewUserDocuments: access.canViewUserDocuments,
|
||||||
canVerifyUsers: access.canVerifyUsers
|
canVerifyUsers: access.canVerifyUsers,
|
||||||
|
canManageBots: access.canManageBots
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async issueAccessToken(user: Pick<User, 'id' | 'isSuperAdmin'>, sessionId: string, pinVerified: boolean) {
|
async issueAccessToken(user: Pick<User, 'id' | 'isSuperAdmin' | 'isSystemAccount' | 'linkedBotId'>, sessionId: string, pinVerified: boolean) {
|
||||||
|
assertHumanAccount(user, { asAuth: true });
|
||||||
|
|
||||||
return this.jwt.signAsync(
|
return this.jwt.signAsync(
|
||||||
{
|
{
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
|
|||||||
900
apps/sso-core/src/domain/bot/bot-api.service.ts
Normal file
900
apps/sso-core/src/domain/bot/bot-api.service.ts
Normal file
@@ -0,0 +1,900 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infra/prisma.service';
|
||||||
|
import { NotificationsService } from '../notifications.service';
|
||||||
|
import { BotCallbackRegistryService } from './bot-callback-registry.service';
|
||||||
|
import { formatTokenForLog } from './bot-debug-log.util';
|
||||||
|
import { BotFatherService } from './bot-father.service';
|
||||||
|
import { BotRateLimitService } from './bot-rate-limit.service';
|
||||||
|
import { BotUpdateQueueService } from './bot-update-queue.service';
|
||||||
|
import { deriveTelegramChatId, hashBotToken } from './bot-token.util';
|
||||||
|
import { serializeTelegramResponse, telegramError, telegramOk } from './telegram-response.util';
|
||||||
|
import { TelegramSerializerService } from './telegram-serializer.service';
|
||||||
|
import { WebAppCryptoService } from './webapp-crypto.service';
|
||||||
|
import { BOTFATHER_BOT_USERNAME, buildBotManageWebAppUrl } from './bot-father.constants';
|
||||||
|
import {
|
||||||
|
menuButtonToJson,
|
||||||
|
parseMenuButtonInput,
|
||||||
|
resolveComposerMenuButton,
|
||||||
|
} from './bot-menu-button.util';
|
||||||
|
import { assertHumanAccount } from '../system-account.util';
|
||||||
|
|
||||||
|
type ValidatedBot = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
ownerId: string;
|
||||||
|
webAppUrl?: string | null;
|
||||||
|
menuButton?: unknown;
|
||||||
|
webhookUrl?: string | null;
|
||||||
|
webhookSecretToken?: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
isSystemBot: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChatPayload = {
|
||||||
|
chat_id?: string | number;
|
||||||
|
message_id?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SendMessagePayload = ChatPayload & {
|
||||||
|
text?: string;
|
||||||
|
parse_mode?: string;
|
||||||
|
reply_markup?: unknown;
|
||||||
|
disable_web_page_preview?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditMessageTextPayload = ChatPayload & {
|
||||||
|
text?: string;
|
||||||
|
parse_mode?: string;
|
||||||
|
reply_markup?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditMessageReplyMarkupPayload = ChatPayload & {
|
||||||
|
reply_markup?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnswerCallbackQueryPayload = {
|
||||||
|
callback_query_id?: string;
|
||||||
|
text?: string;
|
||||||
|
show_alert?: boolean;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaPayload = ChatPayload & {
|
||||||
|
photo?: string;
|
||||||
|
document?: string;
|
||||||
|
caption?: string;
|
||||||
|
reply_markup?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SetWebhookPayload = {
|
||||||
|
url?: string;
|
||||||
|
secret_token?: string;
|
||||||
|
drop_pending_updates?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetUpdatesPayload = {
|
||||||
|
offset?: number | string;
|
||||||
|
limit?: number | string;
|
||||||
|
timeout?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SetChatMenuButtonPayload = {
|
||||||
|
chat_id?: string | number | null;
|
||||||
|
menu_button?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BotApiService {
|
||||||
|
private readonly logger = new Logger(BotApiService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly botFather: BotFatherService,
|
||||||
|
private readonly rateLimit: BotRateLimitService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly webAppCrypto: WebAppCryptoService,
|
||||||
|
private readonly updateQueue: BotUpdateQueueService,
|
||||||
|
private readonly serializer: TelegramSerializerService,
|
||||||
|
private readonly callbackRegistry: BotCallbackRegistryService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async executeMethod(token: string, method: string, payload: Record<string, unknown>) {
|
||||||
|
const bot = (await this.botFather.validateBotToken(token)) as ValidatedBot | null;
|
||||||
|
if (!bot) {
|
||||||
|
this.logger.warn(`Unauthorized Bot API call\n${formatTokenForLog(token)}`);
|
||||||
|
return {
|
||||||
|
httpStatus: 401,
|
||||||
|
responseJson: serializeTelegramResponse(telegramError(401, 'Unauthorized'))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateCheck = await this.rateLimit.assertBotApiRateLimit(hashBotToken(token));
|
||||||
|
if (!rateCheck.allowed) {
|
||||||
|
return {
|
||||||
|
httpStatus: 429,
|
||||||
|
responseJson: serializeTelegramResponse(
|
||||||
|
telegramError(429, 'Too Many Requests: retry later', { retry_after: rateCheck.retryAfter })
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedMethod = method.trim();
|
||||||
|
this.logger.debug(`Bot API ${normalizedMethod} bot=@${bot.username}\n${formatTokenForLog(token)}`);
|
||||||
|
|
||||||
|
switch (normalizedMethod) {
|
||||||
|
case 'getMe':
|
||||||
|
return this.wrapOk(this.buildUserObject(bot));
|
||||||
|
case 'sendMessage':
|
||||||
|
return this.sendMessage(bot, payload as SendMessagePayload);
|
||||||
|
case 'editMessageText':
|
||||||
|
return this.editMessageText(bot, payload as EditMessageTextPayload);
|
||||||
|
case 'editMessageReplyMarkup':
|
||||||
|
return this.editMessageReplyMarkup(bot, payload as EditMessageReplyMarkupPayload);
|
||||||
|
case 'answerCallbackQuery':
|
||||||
|
return this.answerCallbackQuery(payload as AnswerCallbackQueryPayload);
|
||||||
|
case 'sendPhoto':
|
||||||
|
return this.sendPhoto(bot, payload as MediaPayload);
|
||||||
|
case 'sendDocument':
|
||||||
|
return this.sendDocument(bot, payload as MediaPayload);
|
||||||
|
case 'setWebhook':
|
||||||
|
return this.setWebhook(bot, payload as SetWebhookPayload);
|
||||||
|
case 'deleteWebhook':
|
||||||
|
return this.deleteWebhook(bot, payload);
|
||||||
|
case 'getWebhookInfo':
|
||||||
|
return this.getWebhookInfo(bot);
|
||||||
|
case 'getUpdates':
|
||||||
|
return this.getUpdates(bot, payload as GetUpdatesPayload);
|
||||||
|
case 'setChatMenuButton':
|
||||||
|
return this.setChatMenuButton(bot, payload as SetChatMenuButtonPayload);
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
httpStatus: 404,
|
||||||
|
responseJson: serializeTelegramResponse(telegramError(404, `Method '${normalizedMethod}' is not supported`))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateWebAppInitData(initData: string, botToken: string) {
|
||||||
|
this.logger.debug(`Validate WebApp initData\n${formatTokenForLog(botToken)}`);
|
||||||
|
const result = this.webAppCrypto.validateWebAppData(initData, botToken);
|
||||||
|
if (!result.valid) {
|
||||||
|
return { valid: false, error: result.error };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
userJson: result.userJson,
|
||||||
|
authDate: result.authDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendInternalBotMessage(
|
||||||
|
botId: string,
|
||||||
|
recipientUserId: string,
|
||||||
|
text: string,
|
||||||
|
replyMarkup?: Record<string, unknown> | null
|
||||||
|
) {
|
||||||
|
const bot = await this.prisma.bot.findUnique({
|
||||||
|
where: { id: botId },
|
||||||
|
select: { id: true, name: true, username: true, isActive: true }
|
||||||
|
});
|
||||||
|
if (!bot?.isActive) {
|
||||||
|
throw new Error('Системный бот недоступен');
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await this.resolveChatContext(bot.id, recipientUserId);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Получатель не найден');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedMarkup = this.serializer.parseReplyMarkup(replyMarkup ?? null);
|
||||||
|
const message = await this.createOutboundMessage(bot.id, context.chat.id, {
|
||||||
|
messageType: 'text',
|
||||||
|
text,
|
||||||
|
payload: this.serializer.buildStoredPayload(parsedMarkup)
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.publishBotMessage(context.recipient.id, bot as ValidatedBot, context.chat, message, parsedMarkup.internal);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBotChatMessages(userId: string, botRef: string) {
|
||||||
|
const sender = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { isSystemAccount: true, linkedBotId: true, deletedAt: true, status: true }
|
||||||
|
});
|
||||||
|
if (!sender || sender.deletedAt || sender.status !== 'ACTIVE') {
|
||||||
|
return { messages: [], botUsername: botRef, botDisplayName: 'Bot' };
|
||||||
|
}
|
||||||
|
assertHumanAccount(sender, { message: 'Системные учётные записи не могут просматривать чаты ботов' });
|
||||||
|
|
||||||
|
const bot = await this.prisma.bot.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ id: botRef },
|
||||||
|
{ username: botRef },
|
||||||
|
{ username: `${botRef.replace(/_bot$/i, '')}_bot` }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
username: true,
|
||||||
|
isActive: true,
|
||||||
|
isSystemBot: true,
|
||||||
|
webAppUrl: true,
|
||||||
|
menuButton: true,
|
||||||
|
ownerId: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!bot?.isActive) {
|
||||||
|
return {
|
||||||
|
messages: [],
|
||||||
|
botUsername: botRef,
|
||||||
|
botDisplayName: 'Bot',
|
||||||
|
composerWebAppUrl: undefined,
|
||||||
|
composerMenuButtonJson: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await this.prisma.botChat.findUnique({
|
||||||
|
where: { botId_recipientUserId: { botId: bot.id, recipientUserId: userId } },
|
||||||
|
include: { menuButton: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const composer = this.resolveComposerForBot(bot, chat?.menuButton?.menuButton ?? null);
|
||||||
|
const emptyResponse = {
|
||||||
|
messages: [] as Array<Record<string, unknown>>,
|
||||||
|
botUsername: bot.username,
|
||||||
|
botDisplayName: bot.name,
|
||||||
|
botId: bot.id,
|
||||||
|
botOwnerId: bot.ownerId,
|
||||||
|
composerWebAppUrl: composer.composerWebAppUrl,
|
||||||
|
composerMenuButtonJson: composer.composerMenuButtonJson,
|
||||||
|
manageWebAppUrl: buildBotManageWebAppUrl(bot.id)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
return emptyResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [outbound, inbound] = await Promise.all([
|
||||||
|
this.prisma.botMessage.findMany({
|
||||||
|
where: { botChatId: chat.id },
|
||||||
|
orderBy: { createdAt: 'asc' }
|
||||||
|
}),
|
||||||
|
this.prisma.botInboundMessage.findMany({
|
||||||
|
where: { botChatId: chat.id },
|
||||||
|
orderBy: { createdAt: 'asc' }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
...inbound.map((item) => ({
|
||||||
|
id: `in-${item.id}`,
|
||||||
|
direction: 'in' as const,
|
||||||
|
text: item.text ?? '',
|
||||||
|
messageType: 'text',
|
||||||
|
messageId: item.telegramMsgId,
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
replyMarkupJson: null as string | null
|
||||||
|
})),
|
||||||
|
...outbound.map((item) => {
|
||||||
|
const payload = this.serializer.readPayload(item.payload);
|
||||||
|
const replyMarkup = payload.telegramReplyMarkup ?? payload.replyMarkup;
|
||||||
|
return {
|
||||||
|
id: `out-${item.id}`,
|
||||||
|
direction: 'out' as const,
|
||||||
|
text: item.text ?? '',
|
||||||
|
messageType: item.messageType ?? 'text',
|
||||||
|
messageId: item.telegramMsgId,
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
replyMarkupJson: replyMarkup ? JSON.stringify(replyMarkup) : null
|
||||||
|
};
|
||||||
|
})
|
||||||
|
].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
botUsername: bot.username,
|
||||||
|
botDisplayName: bot.name,
|
||||||
|
botId: bot.id,
|
||||||
|
botOwnerId: bot.ownerId,
|
||||||
|
composerWebAppUrl: composer.composerWebAppUrl,
|
||||||
|
composerMenuButtonJson: composer.composerMenuButtonJson,
|
||||||
|
manageWebAppUrl: buildBotManageWebAppUrl(bot.id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveComposerForBot(
|
||||||
|
bot: { isSystemBot: boolean; username: string; webAppUrl?: string | null; menuButton?: unknown },
|
||||||
|
chatOverride: unknown
|
||||||
|
) {
|
||||||
|
const resolved = resolveComposerMenuButton({
|
||||||
|
chatOverride,
|
||||||
|
globalMenuButton: bot.menuButton,
|
||||||
|
bot
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
composerWebAppUrl: resolved.composerWebAppUrl,
|
||||||
|
composerMenuButtonJson: resolved.menuButton ? JSON.stringify(resolved.menuButton) : undefined,
|
||||||
|
buttonText: resolved.buttonText
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setChatMenuButton(bot: ValidatedBot, payload: SetChatMenuButtonPayload) {
|
||||||
|
const chatIdRaw = payload.chat_id;
|
||||||
|
const parsedMenuButton = parseMenuButtonInput(payload.menu_button);
|
||||||
|
const isGlobal =
|
||||||
|
chatIdRaw === undefined ||
|
||||||
|
chatIdRaw === null ||
|
||||||
|
(typeof chatIdRaw === 'string' && chatIdRaw.trim() === '');
|
||||||
|
|
||||||
|
if (isGlobal) {
|
||||||
|
const stored = menuButtonToJson(parsedMenuButton ?? null);
|
||||||
|
await this.prisma.bot.update({
|
||||||
|
where: { id: bot.id },
|
||||||
|
data: { menuButton: stored as never }
|
||||||
|
});
|
||||||
|
await this.botFather.invalidateBotCacheById(bot.id);
|
||||||
|
|
||||||
|
const chats = await this.prisma.botChat.findMany({
|
||||||
|
where: { botId: bot.id, recipientUserId: { not: null } },
|
||||||
|
include: { menuButton: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
chats.map(async (chat) => {
|
||||||
|
if (!chat.recipientUserId) return;
|
||||||
|
const composer = this.resolveComposerForBot(
|
||||||
|
{ ...bot, menuButton: stored },
|
||||||
|
chat.menuButton?.menuButton ?? null
|
||||||
|
);
|
||||||
|
await this.publishMenuButtonUpdate(chat.recipientUserId, bot, composer);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.wrapOk(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await this.resolveChatContext(bot.id, String(chatIdRaw));
|
||||||
|
if (!context) {
|
||||||
|
return this.wrapBadRequest('Bad Request: chat not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldClear =
|
||||||
|
payload.menu_button === undefined ||
|
||||||
|
parsedMenuButton === null ||
|
||||||
|
parsedMenuButton === undefined ||
|
||||||
|
parsedMenuButton.type === 'default';
|
||||||
|
|
||||||
|
if (shouldClear) {
|
||||||
|
await this.prisma.chatMenuButton.deleteMany({ where: { botChatId: context.chat.id } });
|
||||||
|
} else {
|
||||||
|
const stored = menuButtonToJson(parsedMenuButton);
|
||||||
|
if (!stored) {
|
||||||
|
return this.wrapBadRequest('Bad Request: menu_button is invalid');
|
||||||
|
}
|
||||||
|
await this.prisma.chatMenuButton.upsert({
|
||||||
|
where: { botChatId: context.chat.id },
|
||||||
|
create: {
|
||||||
|
botId: bot.id,
|
||||||
|
botChatId: context.chat.id,
|
||||||
|
menuButton: stored as never
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
menuButton: stored as never
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const freshBot = await this.prisma.bot.findUnique({
|
||||||
|
where: { id: bot.id },
|
||||||
|
select: { menuButton: true, isSystemBot: true, username: true, webAppUrl: true }
|
||||||
|
});
|
||||||
|
const override = shouldClear
|
||||||
|
? null
|
||||||
|
: (await this.prisma.chatMenuButton.findUnique({ where: { botChatId: context.chat.id } }))?.menuButton ?? null;
|
||||||
|
const composer = this.resolveComposerForBot(
|
||||||
|
{
|
||||||
|
isSystemBot: freshBot?.isSystemBot ?? bot.isSystemBot,
|
||||||
|
username: freshBot?.username ?? bot.username,
|
||||||
|
webAppUrl: freshBot?.webAppUrl ?? bot.webAppUrl,
|
||||||
|
menuButton: freshBot?.menuButton
|
||||||
|
},
|
||||||
|
override
|
||||||
|
);
|
||||||
|
await this.publishMenuButtonUpdate(context.recipient.id, bot, composer);
|
||||||
|
|
||||||
|
return this.wrapOk(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async publishMenuButtonUpdate(
|
||||||
|
userId: string,
|
||||||
|
bot: ValidatedBot,
|
||||||
|
composer: { composerWebAppUrl?: string; composerMenuButtonJson?: string; buttonText?: string }
|
||||||
|
) {
|
||||||
|
await this.notifications.publishRealtime(userId, 'bot_menu_button_updated', bot.name, '', {
|
||||||
|
botId: bot.id,
|
||||||
|
botUsername: bot.username,
|
||||||
|
composerWebAppUrl: composer.composerWebAppUrl ?? null,
|
||||||
|
composerMenuButtonJson: composer.composerMenuButtonJson ?? null,
|
||||||
|
buttonText: composer.buttonText ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendMessage(bot: ValidatedBot, payload: SendMessagePayload) {
|
||||||
|
const chatIdRaw = payload.chat_id;
|
||||||
|
const text = payload.text?.trim();
|
||||||
|
if (chatIdRaw === undefined || chatIdRaw === null || chatIdRaw === '') {
|
||||||
|
return this.wrapBadRequest('Bad Request: chat_id is required');
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
return this.wrapBadRequest('Bad Request: text is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await this.resolveChatContext(bot.id, String(chatIdRaw));
|
||||||
|
if (!context) {
|
||||||
|
return this.wrapBadRequest('Bad Request: chat not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedMarkup = this.serializer.parseReplyMarkup(payload.reply_markup);
|
||||||
|
const message = await this.createOutboundMessage(bot.id, context.chat.id, {
|
||||||
|
messageType: 'text',
|
||||||
|
text,
|
||||||
|
payload: this.serializer.buildStoredPayload(parsedMarkup)
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.publishBotMessage(context.recipient.id, bot, context.chat, message, parsedMarkup.internal);
|
||||||
|
|
||||||
|
return this.wrapOk(this.buildTelegramMessage(bot, context, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendPhoto(bot: ValidatedBot, payload: MediaPayload) {
|
||||||
|
const photo = payload.photo?.trim();
|
||||||
|
if (!payload.chat_id || !photo) {
|
||||||
|
return this.wrapBadRequest('Bad Request: chat_id and photo are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await this.resolveChatContext(bot.id, String(payload.chat_id));
|
||||||
|
if (!context) {
|
||||||
|
return this.wrapBadRequest('Bad Request: chat not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedMarkup = this.serializer.parseReplyMarkup(payload.reply_markup);
|
||||||
|
const message = await this.createOutboundMessage(bot.id, context.chat.id, {
|
||||||
|
messageType: 'photo',
|
||||||
|
text: payload.caption?.trim() || null,
|
||||||
|
mediaUrl: photo,
|
||||||
|
payload: this.serializer.buildStoredPayload(parsedMarkup)
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.publishBotMessage(context.recipient.id, bot, context.chat, message, parsedMarkup.internal, 'photo', photo);
|
||||||
|
|
||||||
|
return this.wrapOk(this.buildTelegramMessage(bot, context, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendDocument(bot: ValidatedBot, payload: MediaPayload) {
|
||||||
|
const document = payload.document?.trim();
|
||||||
|
if (!payload.chat_id || !document) {
|
||||||
|
return this.wrapBadRequest('Bad Request: chat_id and document are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await this.resolveChatContext(bot.id, String(payload.chat_id));
|
||||||
|
if (!context) {
|
||||||
|
return this.wrapBadRequest('Bad Request: chat not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedMarkup = this.serializer.parseReplyMarkup(payload.reply_markup);
|
||||||
|
const message = await this.createOutboundMessage(bot.id, context.chat.id, {
|
||||||
|
messageType: 'document',
|
||||||
|
text: payload.caption?.trim() || null,
|
||||||
|
mediaUrl: document,
|
||||||
|
payload: this.serializer.buildStoredPayload(parsedMarkup)
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.publishBotMessage(context.recipient.id, bot, context.chat, message, parsedMarkup.internal, 'document', document);
|
||||||
|
|
||||||
|
return this.wrapOk(this.buildTelegramMessage(bot, context, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async editMessageText(bot: ValidatedBot, payload: EditMessageTextPayload) {
|
||||||
|
const text = payload.text?.trim();
|
||||||
|
if (!payload.chat_id || payload.message_id === undefined) {
|
||||||
|
return this.wrapBadRequest('Bad Request: chat_id and message_id are required');
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
return this.wrapBadRequest('Bad Request: text is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = Number(payload.message_id);
|
||||||
|
if (!Number.isFinite(messageId)) {
|
||||||
|
return this.wrapBadRequest('Bad Request: message_id is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const located = await this.locateMessage(bot.id, String(payload.chat_id), messageId);
|
||||||
|
if (!located) {
|
||||||
|
return this.wrapBadRequest('Bad Request: message to edit not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedMarkup = payload.reply_markup
|
||||||
|
? this.serializer.parseReplyMarkup(payload.reply_markup)
|
||||||
|
: this.serializer.readPayloadAsParsed(located.message.payload);
|
||||||
|
|
||||||
|
const updated = await this.prisma.botMessage.update({
|
||||||
|
where: { id: located.message.id },
|
||||||
|
data: {
|
||||||
|
text,
|
||||||
|
editedAt: new Date(),
|
||||||
|
payload: this.mergePayload(located.message.payload, parsedMarkup)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.publishBotMessageEdited(located.recipient.id, bot, located.chat, updated, parsedMarkup.internal);
|
||||||
|
|
||||||
|
return this.wrapOk(this.buildTelegramMessage(bot, located, updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async editMessageReplyMarkup(bot: ValidatedBot, payload: EditMessageReplyMarkupPayload) {
|
||||||
|
if (!payload.chat_id || payload.message_id === undefined || payload.reply_markup === undefined) {
|
||||||
|
return this.wrapBadRequest('Bad Request: chat_id, message_id and reply_markup are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = Number(payload.message_id);
|
||||||
|
if (!Number.isFinite(messageId)) {
|
||||||
|
return this.wrapBadRequest('Bad Request: message_id is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const located = await this.locateMessage(bot.id, String(payload.chat_id), messageId);
|
||||||
|
if (!located) {
|
||||||
|
return this.wrapBadRequest('Bad Request: message to edit not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedMarkup = this.serializer.parseReplyMarkup(payload.reply_markup);
|
||||||
|
const updated = await this.prisma.botMessage.update({
|
||||||
|
where: { id: located.message.id },
|
||||||
|
data: {
|
||||||
|
editedAt: new Date(),
|
||||||
|
payload: this.mergePayload(located.message.payload, parsedMarkup)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.publishBotMessageEdited(located.recipient.id, bot, located.chat, updated, parsedMarkup.internal);
|
||||||
|
|
||||||
|
return this.wrapOk(this.buildTelegramMessage(bot, located, updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async answerCallbackQuery(payload: AnswerCallbackQueryPayload) {
|
||||||
|
const callbackQueryId = payload.callback_query_id?.trim();
|
||||||
|
if (!callbackQueryId) {
|
||||||
|
return this.wrapBadRequest('Bad Request: callback_query_id is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const registered = await this.callbackRegistry.resolve(callbackQueryId);
|
||||||
|
if (!registered) {
|
||||||
|
return this.wrapBadRequest('Bad Request: callback query not found or expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.notifications.publishRealtime(registered.userId, 'bot_callback_answer', '', payload.text?.trim() ?? '', {
|
||||||
|
callbackQueryId,
|
||||||
|
text: payload.text?.trim() ?? null,
|
||||||
|
showAlert: Boolean(payload.show_alert),
|
||||||
|
url: payload.url?.trim() ?? null,
|
||||||
|
botId: registered.botId
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.wrapOk(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setWebhook(bot: ValidatedBot, payload: SetWebhookPayload) {
|
||||||
|
const url = payload.url?.trim();
|
||||||
|
if (!url) {
|
||||||
|
return this.wrapBadRequest('Bad Request: url is required');
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\//i.test(url)) {
|
||||||
|
return this.wrapBadRequest('Bad Request: invalid webhook url');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.drop_pending_updates) {
|
||||||
|
await this.updateQueue.clearQueue(bot.id);
|
||||||
|
await this.prisma.botUpdate.deleteMany({ where: { botId: bot.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.bot.update({
|
||||||
|
where: { id: bot.id },
|
||||||
|
data: {
|
||||||
|
webhookUrl: url,
|
||||||
|
webhookSecretToken: payload.secret_token?.trim() || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.botFather.invalidateBotCacheById(bot.id);
|
||||||
|
return this.wrapOk(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteWebhook(bot: ValidatedBot, payload: Record<string, unknown>) {
|
||||||
|
if (payload.drop_pending_updates) {
|
||||||
|
await this.updateQueue.clearQueue(bot.id);
|
||||||
|
await this.prisma.botUpdate.deleteMany({ where: { botId: bot.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.bot.update({
|
||||||
|
where: { id: bot.id },
|
||||||
|
data: {
|
||||||
|
webhookUrl: null,
|
||||||
|
webhookSecretToken: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.botFather.invalidateBotCacheById(bot.id);
|
||||||
|
return this.wrapOk(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getWebhookInfo(bot: ValidatedBot) {
|
||||||
|
const record = await this.prisma.bot.findUnique({
|
||||||
|
where: { id: bot.id },
|
||||||
|
select: { webhookUrl: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = record?.webhookUrl ? 0 : await this.updateQueue.pendingCount(bot.id);
|
||||||
|
|
||||||
|
return this.wrapOk({
|
||||||
|
url: record?.webhookUrl ?? '',
|
||||||
|
has_custom_certificate: false,
|
||||||
|
pending_update_count: pending,
|
||||||
|
max_connections: 40
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUpdates(bot: ValidatedBot, payload: GetUpdatesPayload) {
|
||||||
|
const record = await this.prisma.bot.findUnique({
|
||||||
|
where: { id: bot.id },
|
||||||
|
select: { webhookUrl: true }
|
||||||
|
});
|
||||||
|
if (record?.webhookUrl) {
|
||||||
|
return this.wrapBadRequest("Bad Request: can't use getUpdates while webhook is active; use deleteWebhook first");
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = payload.offset !== undefined ? Number(payload.offset) : 0;
|
||||||
|
const limit = payload.limit !== undefined ? Number(payload.limit) : 100;
|
||||||
|
const timeout = payload.timeout !== undefined ? Number(payload.timeout) : 0;
|
||||||
|
|
||||||
|
if (!Number.isFinite(offset) || offset < 0) {
|
||||||
|
return this.wrapBadRequest('Bad Request: offset is invalid');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(limit) || limit <= 0) {
|
||||||
|
return this.wrapBadRequest('Bad Request: limit is invalid');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(timeout) || timeout < 0) {
|
||||||
|
return this.wrapBadRequest('Bad Request: timeout is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = await this.updateQueue.getUpdates(bot.id, offset, limit, timeout);
|
||||||
|
return this.wrapOk(updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createOutboundMessage(
|
||||||
|
botId: string,
|
||||||
|
botChatId: string,
|
||||||
|
data: {
|
||||||
|
messageType: string;
|
||||||
|
text?: string | null;
|
||||||
|
mediaUrl?: string | null;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const currentBot = await tx.bot.update({
|
||||||
|
where: { id: botId },
|
||||||
|
data: { nextMessageId: { increment: 1 } },
|
||||||
|
select: { nextMessageId: true }
|
||||||
|
});
|
||||||
|
const telegramMsgId = currentBot.nextMessageId - 1;
|
||||||
|
|
||||||
|
return tx.botMessage.create({
|
||||||
|
data: {
|
||||||
|
botId,
|
||||||
|
botChatId,
|
||||||
|
telegramMsgId,
|
||||||
|
messageType: data.messageType,
|
||||||
|
text: data.text ?? null,
|
||||||
|
mediaUrl: data.mediaUrl ?? null,
|
||||||
|
payload: data.payload as never
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async locateMessage(botId: string, chatIdRaw: string, telegramMsgId: number) {
|
||||||
|
const context = await this.resolveChatContext(botId, chatIdRaw);
|
||||||
|
if (!context) return null;
|
||||||
|
|
||||||
|
const message = await this.prisma.botMessage.findFirst({
|
||||||
|
where: {
|
||||||
|
botId,
|
||||||
|
botChatId: context.chat.id,
|
||||||
|
telegramMsgId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
return { ...context, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveChatContext(botId: string, chatIdRaw: string) {
|
||||||
|
const recipient = await this.resolveRecipient(chatIdRaw, botId);
|
||||||
|
if (!recipient) return null;
|
||||||
|
|
||||||
|
const chat = await this.ensureBotChat(botId, recipient.id, chatIdRaw);
|
||||||
|
return { recipient, chat };
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTelegramMessage(
|
||||||
|
bot: ValidatedBot,
|
||||||
|
context: {
|
||||||
|
recipient: { id: string; displayName: string; username: string | null };
|
||||||
|
chat: { telegramChatId: bigint; chatType: string };
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
telegramMsgId: number;
|
||||||
|
text?: string | null;
|
||||||
|
messageType?: string | null;
|
||||||
|
mediaUrl?: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
editedAt?: Date | null;
|
||||||
|
payload?: unknown;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return this.serializer.buildBotMessageObject({
|
||||||
|
message,
|
||||||
|
bot,
|
||||||
|
chat: {
|
||||||
|
telegramChatId: context.chat.telegramChatId,
|
||||||
|
chatType: context.chat.chatType,
|
||||||
|
recipientDisplayName: context.recipient.displayName,
|
||||||
|
recipientUsername: context.recipient.username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async publishBotMessage(
|
||||||
|
userId: string,
|
||||||
|
bot: ValidatedBot,
|
||||||
|
chat: { telegramChatId: bigint; chatType: string },
|
||||||
|
message: {
|
||||||
|
telegramMsgId: number;
|
||||||
|
text?: string | null;
|
||||||
|
messageType?: string | null;
|
||||||
|
mediaUrl?: string | null;
|
||||||
|
payload?: unknown;
|
||||||
|
},
|
||||||
|
replyMarkup: unknown,
|
||||||
|
mediaType?: string,
|
||||||
|
mediaUrl?: string
|
||||||
|
) {
|
||||||
|
const payloadMarkup = this.serializer.readPayload(message.payload);
|
||||||
|
await this.notifications.publishRealtime(userId, 'bot_message', bot.name, message.text ?? '', {
|
||||||
|
botId: bot.id,
|
||||||
|
botUsername: bot.username,
|
||||||
|
chatId: chat.telegramChatId.toString(),
|
||||||
|
messageId: message.telegramMsgId,
|
||||||
|
text: message.text ?? null,
|
||||||
|
messageType: message.messageType ?? mediaType ?? 'text',
|
||||||
|
mediaUrl: message.mediaUrl ?? mediaUrl ?? null,
|
||||||
|
replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null,
|
||||||
|
telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async publishBotMessageEdited(
|
||||||
|
userId: string,
|
||||||
|
bot: ValidatedBot,
|
||||||
|
chat: { telegramChatId: bigint; chatType: string },
|
||||||
|
message: {
|
||||||
|
telegramMsgId: number;
|
||||||
|
text?: string | null;
|
||||||
|
messageType?: string | null;
|
||||||
|
mediaUrl?: string | null;
|
||||||
|
payload?: unknown;
|
||||||
|
editedAt?: Date | null;
|
||||||
|
},
|
||||||
|
replyMarkup: unknown
|
||||||
|
) {
|
||||||
|
const payloadMarkup = this.serializer.readPayload(message.payload);
|
||||||
|
await this.notifications.publishRealtime(userId, 'bot_message_edited', bot.name, message.text ?? '', {
|
||||||
|
botId: bot.id,
|
||||||
|
botUsername: bot.username,
|
||||||
|
chatId: chat.telegramChatId.toString(),
|
||||||
|
messageId: message.telegramMsgId,
|
||||||
|
text: message.text ?? null,
|
||||||
|
messageType: message.messageType ?? 'text',
|
||||||
|
mediaUrl: message.mediaUrl ?? null,
|
||||||
|
replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null,
|
||||||
|
telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null,
|
||||||
|
editedAt: message.editedAt?.toISOString() ?? new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergePayload(existing: unknown, parsed: ReturnType<TelegramSerializerService['parseReplyMarkup']>) {
|
||||||
|
const current = existing && typeof existing === 'object' ? (existing as Record<string, unknown>) : {};
|
||||||
|
const stored = this.serializer.buildStoredPayload(parsed);
|
||||||
|
return (stored ? { ...current, ...stored } : current) as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRecipient(chatId: string, botId: string) {
|
||||||
|
const numericChatId = chatId.match(/^\d+$/) ? BigInt(chatId) : null;
|
||||||
|
if (numericChatId) {
|
||||||
|
const chat = await this.prisma.botChat.findUnique({
|
||||||
|
where: { botId_telegramChatId: { botId, telegramChatId: numericChatId } }
|
||||||
|
});
|
||||||
|
if (!chat?.recipientUserId) return null;
|
||||||
|
return this.prisma.user.findUnique({
|
||||||
|
where: { id: chat.recipientUserId },
|
||||||
|
select: { id: true, displayName: true, username: true, status: true, deletedAt: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: chatId },
|
||||||
|
select: { id: true, displayName: true, username: true, status: true, deletedAt: true }
|
||||||
|
});
|
||||||
|
if (!user || user.deletedAt || user.status !== 'ACTIVE') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureBotChat(botId: string, recipientUserId: string, chatIdRaw: string) {
|
||||||
|
const existing = await this.prisma.botChat.findUnique({
|
||||||
|
where: { botId_recipientUserId: { botId, recipientUserId } }
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const telegramChatId = chatIdRaw.match(/^\d+$/) ? BigInt(chatIdRaw) : deriveTelegramChatId(`${botId}:${recipientUserId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.prisma.botChat.create({
|
||||||
|
data: {
|
||||||
|
botId,
|
||||||
|
recipientUserId,
|
||||||
|
telegramChatId,
|
||||||
|
chatType: 'private'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
const fallback = await this.prisma.botChat.findUnique({
|
||||||
|
where: { botId_recipientUserId: { botId, recipientUserId } }
|
||||||
|
});
|
||||||
|
if (!fallback) {
|
||||||
|
throw new Error('Не удалось создать чат бота');
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildUserObject(bot: ValidatedBot) {
|
||||||
|
return {
|
||||||
|
id: Number(deriveTelegramChatId(bot.id)),
|
||||||
|
is_bot: true,
|
||||||
|
first_name: bot.name,
|
||||||
|
username: bot.username,
|
||||||
|
can_join_groups: true,
|
||||||
|
can_read_all_group_messages: false,
|
||||||
|
supports_inline_queries: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private wrapOk(result: unknown) {
|
||||||
|
return {
|
||||||
|
httpStatus: 200,
|
||||||
|
responseJson: serializeTelegramResponse(telegramOk(result))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private wrapBadRequest(description: string) {
|
||||||
|
return {
|
||||||
|
httpStatus: 200,
|
||||||
|
responseJson: serializeTelegramResponse(telegramError(400, description))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { RedisService } from '../../infra/redis.service';
|
||||||
|
|
||||||
|
export type RegisteredCallbackQuery = {
|
||||||
|
userId: string;
|
||||||
|
botId: string;
|
||||||
|
callbackData: string;
|
||||||
|
messageId: number;
|
||||||
|
chatId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BotCallbackRegistryService {
|
||||||
|
constructor(private readonly redis: RedisService) {}
|
||||||
|
|
||||||
|
private key(callbackQueryId: string) {
|
||||||
|
return `bot:callback:${callbackQueryId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(callbackQueryId: string, payload: RegisteredCallbackQuery) {
|
||||||
|
await this.redis.client.set(this.key(callbackQueryId), JSON.stringify(payload), 'EX', 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolve(callbackQueryId: string) {
|
||||||
|
const raw = await this.redis.client.get(this.key(callbackQueryId));
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as RegisteredCallbackQuery;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/sso-core/src/domain/bot/bot-debug-log.util.ts
Normal file
9
apps/sso-core/src/domain/bot/bot-debug-log.util.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function formatTokenForLog(value: string, chunkSize = 48) {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized) return '(empty)';
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let index = 0; index < normalized.length; index += chunkSize) {
|
||||||
|
chunks.push(normalized.slice(index, index + chunkSize));
|
||||||
|
}
|
||||||
|
return chunks.join('\n');
|
||||||
|
}
|
||||||
163
apps/sso-core/src/domain/bot/bot-delivery.service.ts
Normal file
163
apps/sso-core/src/domain/bot/bot-delivery.service.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infra/prisma.service';
|
||||||
|
import { BotCallbackRegistryService } from './bot-callback-registry.service';
|
||||||
|
import { BotInboundQueueMessage } from './bot-inbound-publisher.service';
|
||||||
|
import { BotUpdateQueueService } from './bot-update-queue.service';
|
||||||
|
import { BotWebhookDispatcherService } from './bot-webhook-dispatcher.service';
|
||||||
|
import { TelegramSerializerService } from './telegram-serializer.service';
|
||||||
|
import { isProtectedSystemAccount } from '../system-account.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BotDeliveryService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly serializer: TelegramSerializerService,
|
||||||
|
private readonly webhookDispatcher: BotWebhookDispatcherService,
|
||||||
|
private readonly updateQueue: BotUpdateQueueService,
|
||||||
|
private readonly callbackRegistry: BotCallbackRegistryService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async deliverInboundEvent(event: BotInboundQueueMessage) {
|
||||||
|
if ((event.eventType ?? 'message') === 'callback_query') {
|
||||||
|
await this.deliverCallbackQuery(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.deliverMessage(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverMessage(event: BotInboundQueueMessage) {
|
||||||
|
const [bot, sender] = await Promise.all([
|
||||||
|
this.loadBot(event.botId),
|
||||||
|
this.loadSender(event.senderUserId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!bot || !sender) return;
|
||||||
|
|
||||||
|
const internalMessage = {
|
||||||
|
updateId: event.updateId,
|
||||||
|
botId: event.botId,
|
||||||
|
senderUserId: sender.id,
|
||||||
|
senderDisplayName: sender.displayName,
|
||||||
|
senderUsername: sender.username,
|
||||||
|
telegramChatId: Number(event.telegramChatId),
|
||||||
|
telegramMessageId: event.telegramMessageId,
|
||||||
|
text: event.text,
|
||||||
|
createdAt: new Date(event.createdAt)
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = this.serializer.toTelegramUpdate(internalMessage);
|
||||||
|
await this.persistAndDispatch(bot, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverCallbackQuery(event: BotInboundQueueMessage) {
|
||||||
|
const [bot, sender, sourceMessage] = await Promise.all([
|
||||||
|
this.loadBot(event.botId),
|
||||||
|
this.loadSender(event.senderUserId),
|
||||||
|
event.sourceBotMessageId
|
||||||
|
? this.prisma.botMessage.findFirst({
|
||||||
|
where: {
|
||||||
|
botId: event.botId,
|
||||||
|
botChatId: event.botChatId,
|
||||||
|
telegramMsgId: event.sourceBotMessageId
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
botChat: {
|
||||||
|
include: {
|
||||||
|
bot: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: Promise.resolve(null)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!bot || !sender || !sourceMessage || !event.callbackQueryId || !event.callbackData) return;
|
||||||
|
|
||||||
|
const recipient = await this.prisma.user.findUnique({
|
||||||
|
where: { id: sender.id },
|
||||||
|
select: { displayName: true, username: true }
|
||||||
|
});
|
||||||
|
if (!recipient) return;
|
||||||
|
|
||||||
|
const telegramMessage = this.serializer.buildBotMessageObject({
|
||||||
|
message: sourceMessage,
|
||||||
|
bot: sourceMessage.botChat.bot,
|
||||||
|
chat: {
|
||||||
|
telegramChatId: sourceMessage.botChat.telegramChatId,
|
||||||
|
chatType: sourceMessage.botChat.chatType,
|
||||||
|
recipientDisplayName: recipient.displayName,
|
||||||
|
recipientUsername: recipient.username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const callbackQueryId = event.callbackQueryId;
|
||||||
|
await this.callbackRegistry.register(callbackQueryId, {
|
||||||
|
userId: sender.id,
|
||||||
|
botId: bot.id,
|
||||||
|
callbackData: event.callbackData,
|
||||||
|
messageId: sourceMessage.telegramMsgId,
|
||||||
|
chatId: Number(event.telegramChatId)
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = this.serializer.toTelegramCallbackUpdate({
|
||||||
|
updateId: event.updateId,
|
||||||
|
botId: event.botId,
|
||||||
|
callbackQueryId,
|
||||||
|
senderUserId: sender.id,
|
||||||
|
senderDisplayName: sender.displayName,
|
||||||
|
senderUsername: sender.username,
|
||||||
|
telegramChatId: Number(event.telegramChatId),
|
||||||
|
callbackData: event.callbackData,
|
||||||
|
sourceMessage: telegramMessage,
|
||||||
|
createdAt: new Date(event.createdAt)
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.persistAndDispatch(bot, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistAndDispatch(
|
||||||
|
bot: { id: string; webhookUrl: string | null; webhookSecretToken: string | null },
|
||||||
|
update: ReturnType<TelegramSerializerService['toTelegramUpdate']> | ReturnType<TelegramSerializerService['toTelegramCallbackUpdate']>
|
||||||
|
) {
|
||||||
|
await this.prisma.botUpdate.upsert({
|
||||||
|
where: { botId_updateId: { botId: bot.id, updateId: update.update_id } },
|
||||||
|
create: {
|
||||||
|
botId: bot.id,
|
||||||
|
updateId: update.update_id,
|
||||||
|
payload: update as object
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
payload: update as object
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bot.webhookUrl) {
|
||||||
|
await this.webhookDispatcher.dispatch(bot.webhookUrl, update, bot.webhookSecretToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateQueue.enqueue(bot.id, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadBot(botId: string) {
|
||||||
|
return this.prisma.bot.findUnique({
|
||||||
|
where: { id: botId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
isActive: true,
|
||||||
|
webhookUrl: true,
|
||||||
|
webhookSecretToken: true
|
||||||
|
}
|
||||||
|
}).then((bot) => (bot?.isActive ? bot : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadSender(senderUserId: string) {
|
||||||
|
return this.prisma.user.findUnique({
|
||||||
|
where: { id: senderUserId },
|
||||||
|
select: { id: true, displayName: true, username: true, deletedAt: true, status: true, isSystemAccount: true, linkedBotId: true }
|
||||||
|
}).then((sender) =>
|
||||||
|
sender && !sender.deletedAt && sender.status === 'ACTIVE' && !isProtectedSystemAccount(sender) ? sender : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
203
apps/sso-core/src/domain/bot/bot-father-assistant.service.ts
Normal file
203
apps/sso-core/src/domain/bot/bot-father-assistant.service.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { BotApiService } from './bot-api.service';
|
||||||
|
import { BOTFATHER_BOT_USERNAME, buildBotCreateWebAppUrl, buildBotManageWebAppUrl } from './bot-father.constants';
|
||||||
|
import { BotFatherSeedService } from './bot-father-seed.service';
|
||||||
|
import { BotFatherService } from './bot-father.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BotFatherAssistantService {
|
||||||
|
private readonly logger = new Logger(BotFatherAssistantService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly seed: BotFatherSeedService,
|
||||||
|
private readonly botFather: BotFatherService,
|
||||||
|
private readonly botApi: BotApiService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handleUserMessage(senderUserId: string, text: string) {
|
||||||
|
const botId = await this.seed.getBotFatherBotId();
|
||||||
|
const trimmed = text.trim();
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
|
||||||
|
if (lower === '/start' || lower === '/help') {
|
||||||
|
await this.botApi.sendInternalBotMessage(botId, senderUserId, this.buildWelcomeText(), {
|
||||||
|
inline_keyboard: [
|
||||||
|
[{ text: '🤖 Создать бота', web_app: { url: this.botCreateMiniAppUrl() } }],
|
||||||
|
[{ text: '📋 Мои боты', callback_data: 'mybots' }]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.startsWith('/newbot')) {
|
||||||
|
await this.handleNewBotCommand(senderUserId, trimmed, botId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower === '/mybots') {
|
||||||
|
await this.handleMyBots(senderUserId, botId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileCommand = this.parseProfileCommand(trimmed);
|
||||||
|
if (profileCommand) {
|
||||||
|
await this.handleProfileCommand(senderUserId, botId, profileCommand);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.botApi.sendInternalBotMessage(
|
||||||
|
botId,
|
||||||
|
senderUserId,
|
||||||
|
'Неизвестная команда. Отправьте /help для списка команд или /newbot Название username для создания бота.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleCallbackQuery(senderUserId: string, callbackData: string) {
|
||||||
|
const botId = await this.seed.getBotFatherBotId();
|
||||||
|
if (callbackData === 'newbot_help' || callbackData === 'newbot') {
|
||||||
|
await this.botApi.sendInternalBotMessage(
|
||||||
|
botId,
|
||||||
|
senderUserId,
|
||||||
|
'Откройте Mini App «Создать бота» — пошаговый мастер как в Telegram BotFather.\n\nИли отправьте:\n/newbot Название username',
|
||||||
|
{ inline_keyboard: [[{ text: '🤖 Создать бота', web_app: { url: this.botCreateMiniAppUrl() } }]] }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (callbackData === 'mybots') {
|
||||||
|
await this.handleMyBots(senderUserId, botId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMyBots(senderUserId: string, botId: string) {
|
||||||
|
const { bots, total } = await this.botFather.listMyBots(senderUserId);
|
||||||
|
const list = bots.length
|
||||||
|
? bots.map((bot) => `• @${bot.username} — ${bot.name}`).join('\n')
|
||||||
|
: 'У вас пока нет ботов.';
|
||||||
|
const keyboard = bots.map((bot) => [
|
||||||
|
{ text: `⚙️ ${bot.name}`, web_app: { url: buildBotManageWebAppUrl(bot.id) } }
|
||||||
|
]);
|
||||||
|
await this.botApi.sendInternalBotMessage(
|
||||||
|
botId,
|
||||||
|
senderUserId,
|
||||||
|
`Ваши боты (${total}):\n\n${list}${bots.length ? '\n\nНажмите кнопку ниже, чтобы открыть настройки бота.' : ''}`,
|
||||||
|
keyboard.length ? { inline_keyboard: keyboard } : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseProfileCommand(text: string):
|
||||||
|
| { kind: 'setdescription' | 'setabouttext' | 'setuserpic' | 'setmenubutton'; botUsername: string; value: string }
|
||||||
|
| null {
|
||||||
|
const match = text.match(/^\/(setdescription|setabouttext|setuserpic|setmenubutton)\s+@?(\S+)\s+([\s\S]+)$/i);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: match[1]!.toLowerCase() as 'setdescription' | 'setabouttext' | 'setuserpic' | 'setmenubutton',
|
||||||
|
botUsername: match[2]!,
|
||||||
|
value: match[3]!.trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleProfileCommand(
|
||||||
|
senderUserId: string,
|
||||||
|
botId: string,
|
||||||
|
command: { kind: 'setdescription' | 'setabouttext' | 'setuserpic' | 'setmenubutton'; botUsername: string; value: string }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (command.kind === 'setdescription') {
|
||||||
|
await this.botFather.updateBotProfileByOwner(senderUserId, command.botUsername, { description: command.value });
|
||||||
|
await this.botApi.sendInternalBotMessage(
|
||||||
|
botId,
|
||||||
|
senderUserId,
|
||||||
|
`✅ Описание бота @${command.botUsername.replace(/_bot$/i, '')}_bot обновлено.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.kind === 'setabouttext') {
|
||||||
|
await this.botFather.updateBotProfileByOwner(senderUserId, command.botUsername, { aboutText: command.value });
|
||||||
|
await this.botApi.sendInternalBotMessage(
|
||||||
|
botId,
|
||||||
|
senderUserId,
|
||||||
|
`✅ Краткое описание бота @${command.botUsername.replace(/_bot$/i, '')}_bot обновлено.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.kind === 'setuserpic') {
|
||||||
|
await this.botFather.updateBotProfileByOwner(senderUserId, command.botUsername, { botPicUrl: command.value });
|
||||||
|
await this.botApi.sendInternalBotMessage(
|
||||||
|
botId,
|
||||||
|
senderUserId,
|
||||||
|
`✅ Аватар бота @${command.botUsername.replace(/_bot$/i, '')}_bot обновлён.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.botFather.updateBotProfileByOwner(senderUserId, command.botUsername, { menuButton: command.value });
|
||||||
|
await this.botApi.sendInternalBotMessage(
|
||||||
|
botId,
|
||||||
|
senderUserId,
|
||||||
|
`✅ Глобальная кнопка меню бота @${command.botUsername.replace(/_bot$/i, '')}_bot обновлена.`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Не удалось обновить профиль бота';
|
||||||
|
this.logger.warn(`BotFather profile command error for ${senderUserId}: ${message}`);
|
||||||
|
await this.botApi.sendInternalBotMessage(botId, senderUserId, `❌ ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private botCreateMiniAppUrl() {
|
||||||
|
return buildBotCreateWebAppUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleNewBotCommand(senderUserId: string, text: string, botId: string) {
|
||||||
|
const parts = text.split(/\s+/);
|
||||||
|
if (parts.length < 3) {
|
||||||
|
await this.botApi.sendInternalBotMessage(
|
||||||
|
botId,
|
||||||
|
senderUserId,
|
||||||
|
'Создайте бота через Mini App — это быстрее и удобнее, чем вводить команду вручную.',
|
||||||
|
{ inline_keyboard: [[{ text: '🤖 Создать бота', web_app: { url: this.botCreateMiniAppUrl() } }]] }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = parts[parts.length - 1]!;
|
||||||
|
const name = parts.slice(1, -1).join(' ').trim();
|
||||||
|
if (!name) {
|
||||||
|
await this.botApi.sendInternalBotMessage(botId, senderUserId, 'Укажите название бота перед username.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.botFather.createBot(senderUserId, name, username);
|
||||||
|
await this.botApi.sendInternalBotMessage(
|
||||||
|
botId,
|
||||||
|
senderUserId,
|
||||||
|
`✅ Бот @${result.bot.username} создан!\n\nСохраните токен — он больше не будет показан:\n${result.token}\n\nУправление: раздел «Боты» или REST /bots`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Не удалось создать бота';
|
||||||
|
this.logger.warn(`BotFather createBot error for ${senderUserId}: ${message}`);
|
||||||
|
await this.botApi.sendInternalBotMessage(botId, senderUserId, `❌ ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWelcomeText() {
|
||||||
|
return [
|
||||||
|
`Привет! Я ${BOTFATHER_BOT_USERNAME.replace(/_bot$/, '')} — помогу создать Telegram-бота в Lendry ID.`,
|
||||||
|
'',
|
||||||
|
'Команды:',
|
||||||
|
'/newbot — создать бота (Mini App или команда с названием и username)',
|
||||||
|
'/mybots — список ваших ботов',
|
||||||
|
'/setdescription username текст — описание перед /start',
|
||||||
|
'/setabouttext username текст — краткое описание в профиле',
|
||||||
|
'/setuserpic username URL — аватар бота',
|
||||||
|
'/setmenubutton username JSON|URL — глобальная кнопка меню (Web App)',
|
||||||
|
'/help — эта справка',
|
||||||
|
'',
|
||||||
|
'Username указывайте без суффикса _bot — он добавится автоматически.'
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
138
apps/sso-core/src/domain/bot/bot-father-seed.service.ts
Normal file
138
apps/sso-core/src/domain/bot/bot-father-seed.service.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infra/prisma.service';
|
||||||
|
import {
|
||||||
|
BOTFATHER_BOT_USERNAME,
|
||||||
|
BOTFATHER_DISPLAY_NAME,
|
||||||
|
BOTFATHER_SETTING_BOT_ID,
|
||||||
|
BOTFATHER_SETTING_USER_ID,
|
||||||
|
BOTFATHER_USER_USERNAME,
|
||||||
|
SYSTEM_BOT_OWNER_EMAIL
|
||||||
|
} from './bot-father.constants';
|
||||||
|
import { generateTelegramStyleBotToken, hashBotToken } from './bot-token.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BotFatherSeedService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(BotFatherSeedService.name);
|
||||||
|
private cachedUserId: string | null = null;
|
||||||
|
private cachedBotId: string | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.ensureBotFather();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBotFatherUserId() {
|
||||||
|
if (this.cachedUserId) return this.cachedUserId;
|
||||||
|
const setting = await this.prisma.systemSetting.findUnique({ where: { key: BOTFATHER_SETTING_USER_ID } });
|
||||||
|
if (setting?.value) {
|
||||||
|
this.cachedUserId = setting.value;
|
||||||
|
return setting.value;
|
||||||
|
}
|
||||||
|
const ensured = await this.ensureBotFather();
|
||||||
|
return ensured.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBotFatherBotId() {
|
||||||
|
if (this.cachedBotId) return this.cachedBotId;
|
||||||
|
const setting = await this.prisma.systemSetting.findUnique({ where: { key: BOTFATHER_SETTING_BOT_ID } });
|
||||||
|
if (setting?.value) {
|
||||||
|
this.cachedBotId = setting.value;
|
||||||
|
return setting.value;
|
||||||
|
}
|
||||||
|
const ensured = await this.ensureBotFather();
|
||||||
|
return ensured.botId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureBotFather() {
|
||||||
|
const existingBotId = await this.prisma.systemSetting.findUnique({ where: { key: BOTFATHER_SETTING_BOT_ID } });
|
||||||
|
const existingUserId = await this.prisma.systemSetting.findUnique({ where: { key: BOTFATHER_SETTING_USER_ID } });
|
||||||
|
if (existingBotId?.value && existingUserId?.value) {
|
||||||
|
this.cachedBotId = existingBotId.value;
|
||||||
|
this.cachedUserId = existingUserId.value;
|
||||||
|
return { userId: existingUserId.value, botId: existingBotId.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerId = await this.resolveBotOwnerId();
|
||||||
|
let botFatherUser = await this.prisma.user.findFirst({
|
||||||
|
where: { username: BOTFATHER_USER_USERNAME, isSystemAccount: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!botFatherUser) {
|
||||||
|
botFatherUser = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
displayName: BOTFATHER_DISPLAY_NAME,
|
||||||
|
username: BOTFATHER_USER_USERNAME,
|
||||||
|
isSystemAccount: true,
|
||||||
|
isVerified: true,
|
||||||
|
verificationIcon: 'bot'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.logger.log(`Создан системный пользователь ${BOTFATHER_DISPLAY_NAME}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bot = await this.prisma.bot.findUnique({ where: { username: BOTFATHER_BOT_USERNAME } });
|
||||||
|
if (!bot) {
|
||||||
|
const { token, tokenPrefix } = generateTelegramStyleBotToken();
|
||||||
|
bot = await this.prisma.bot.create({
|
||||||
|
data: {
|
||||||
|
name: BOTFATHER_DISPLAY_NAME,
|
||||||
|
username: BOTFATHER_BOT_USERNAME,
|
||||||
|
tokenHash: hashBotToken(token),
|
||||||
|
tokenPrefix,
|
||||||
|
ownerId,
|
||||||
|
isSystemBot: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.logger.log(`Создан системный бот @${BOTFATHER_BOT_USERNAME}`);
|
||||||
|
} else if (!bot.isSystemBot) {
|
||||||
|
bot = await this.prisma.bot.update({
|
||||||
|
where: { id: bot.id },
|
||||||
|
data: { isSystemBot: true, isActive: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (botFatherUser.linkedBotId !== bot.id) {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: botFatherUser.id },
|
||||||
|
data: { linkedBotId: bot.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.upsertSetting(BOTFATHER_SETTING_USER_ID, botFatherUser.id, 'UUID системного пользователя BotFather');
|
||||||
|
await this.upsertSetting(BOTFATHER_SETTING_BOT_ID, bot.id, 'UUID системного бота BotFather');
|
||||||
|
|
||||||
|
this.cachedUserId = botFatherUser.id;
|
||||||
|
this.cachedBotId = bot.id;
|
||||||
|
return { userId: botFatherUser.id, botId: bot.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveBotOwnerId() {
|
||||||
|
const superAdmin = await this.prisma.user.findFirst({
|
||||||
|
where: { isSuperAdmin: true, deletedAt: null, status: 'ACTIVE', isSystemAccount: false },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
if (superAdmin) return superAdmin.id;
|
||||||
|
|
||||||
|
const systemOwner = await this.prisma.user.findUnique({ where: { email: SYSTEM_BOT_OWNER_EMAIL } });
|
||||||
|
if (systemOwner) return systemOwner.id;
|
||||||
|
|
||||||
|
const created = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: SYSTEM_BOT_OWNER_EMAIL,
|
||||||
|
displayName: 'System Bot Owner',
|
||||||
|
isSystemAccount: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertSetting(key: string, value: string, description: string) {
|
||||||
|
await this.prisma.systemSetting.upsert({
|
||||||
|
where: { key },
|
||||||
|
create: { key, value, description },
|
||||||
|
update: { value, description }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/sso-core/src/domain/bot/bot-father.constants.ts
Normal file
23
apps/sso-core/src/domain/bot/bot-father.constants.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export const BOTFATHER_BOT_USERNAME = 'BotFather_bot';
|
||||||
|
export const BOTFATHER_USER_USERNAME = 'BotFather';
|
||||||
|
export const BOTFATHER_DISPLAY_NAME = 'BotFather';
|
||||||
|
export const BOTFATHER_SETTING_USER_ID = 'BOTFATHER_USER_ID';
|
||||||
|
export const BOTFATHER_SETTING_BOT_ID = 'BOTFATHER_BOT_ID';
|
||||||
|
export const SYSTEM_BOT_OWNER_EMAIL = 'system-bot-owner@internal.lendry.id';
|
||||||
|
|
||||||
|
export function resolvePublicFrontendUrl() {
|
||||||
|
return (process.env.PUBLIC_FRONTEND_URL ?? 'http://localhost:3002').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBotManageWebAppUrl(botId: string) {
|
||||||
|
return `${resolvePublicFrontendUrl()}/mini-apps/bot-manage?botId=${botId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBotCreateWebAppUrl() {
|
||||||
|
return `${resolvePublicFrontendUrl()}/mini-apps/bot-create`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBotManageWebAppUrl(url: string | null | undefined) {
|
||||||
|
if (!url?.trim()) return false;
|
||||||
|
return url.includes('/mini-apps/bot-manage');
|
||||||
|
}
|
||||||
591
apps/sso-core/src/domain/bot/bot-father.service.ts
Normal file
591
apps/sso-core/src/domain/bot/bot-father.service.ts
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
OnModuleInit
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infra/prisma.service';
|
||||||
|
import { AccessService } from '../access.service';
|
||||||
|
import { NotificationsService } from '../notifications.service';
|
||||||
|
import { SettingsService } from '../settings.service';
|
||||||
|
import { BotRateLimitService } from './bot-rate-limit.service';
|
||||||
|
import { assertValidBotUsername, generateTelegramStyleBotToken, hashBotToken, normalizeBotUsername } from './bot-token.util';
|
||||||
|
import { menuButtonToJson, parseMenuButtonInput, resolveComposerMenuButton } from './bot-menu-button.util';
|
||||||
|
import { assertNotReservedUsername, isProtectedSystemAccount } from '../system-account.util';
|
||||||
|
|
||||||
|
type BotRecord = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
tokenHash: string;
|
||||||
|
tokenPrefix: string;
|
||||||
|
ownerId: string;
|
||||||
|
description?: string | null;
|
||||||
|
aboutText?: string | null;
|
||||||
|
botPicUrl?: string | null;
|
||||||
|
menuButton?: unknown;
|
||||||
|
webAppUrl: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
isSystemBot: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
owner?: { id: string; displayName: string; username: string | null };
|
||||||
|
_count?: { messages: number; chats: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BotFatherService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(BotFatherService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly access: AccessService,
|
||||||
|
private readonly settings: SettingsService,
|
||||||
|
private readonly rateLimit: BotRateLimitService,
|
||||||
|
private readonly notifications: NotificationsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.backfillBotSystemUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureBotSystemUser(botId: string) {
|
||||||
|
const bot = await this.prisma.bot.findUnique({
|
||||||
|
where: { id: botId },
|
||||||
|
include: { linkedSystemUser: { select: { id: true } } }
|
||||||
|
});
|
||||||
|
if (!bot) {
|
||||||
|
throw new NotFoundException('Бот не найден');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bot.linkedSystemUser) {
|
||||||
|
return { userId: bot.linkedSystemUser.id, botId: bot.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.prisma.user.findFirst({
|
||||||
|
where: { linkedBotId: bot.id },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return { userId: existing.id, botId: bot.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemUser = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
displayName: bot.name,
|
||||||
|
isSystemAccount: true,
|
||||||
|
isVerified: true,
|
||||||
|
verificationIcon: 'bot',
|
||||||
|
linkedBotId: bot.id
|
||||||
|
},
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Создан системный пользователь для бота @${bot.username}`);
|
||||||
|
return { userId: systemUser.id, botId: bot.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async backfillBotSystemUsers() {
|
||||||
|
const bots = await this.prisma.bot.findMany({
|
||||||
|
where: { linkedSystemUser: null },
|
||||||
|
select: { id: true, username: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const bot of bots) {
|
||||||
|
try {
|
||||||
|
await this.ensureBotSystemUser(bot.id);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Не удалось создать системного пользователя для @${bot.username}: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBot(ownerId: string, name: string, username: string) {
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
throw new BadRequestException('Укажите название бота');
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedUsername: string;
|
||||||
|
try {
|
||||||
|
normalizedUsername = assertValidBotUsername(username);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('Username бота должен содержать 5–32 символа: латиница, цифры и _, без суффикса _bot');
|
||||||
|
}
|
||||||
|
assertNotReservedUsername(normalizedUsername.replace(/_bot$/, ''));
|
||||||
|
const owner = await this.prisma.user.findUnique({ where: { id: ownerId } });
|
||||||
|
if (!owner || owner.deletedAt || owner.status !== 'ACTIVE') {
|
||||||
|
throw new NotFoundException('Владелец бота не найден');
|
||||||
|
}
|
||||||
|
if (isProtectedSystemAccount(owner)) {
|
||||||
|
throw new BadRequestException('Системные учётные записи не могут владеть ботами');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBots = await this.settings.getNumber('BOT_MAX_BOTS_PER_USER', 5);
|
||||||
|
const ownedCount = await this.prisma.bot.count({ where: { ownerId } });
|
||||||
|
if (ownedCount >= maxBots) {
|
||||||
|
throw new BadRequestException(`Достигнут лимит ботов на пользователя (${maxBots})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitCheck = await this.rateLimit.assertCanCreateBot(ownerId);
|
||||||
|
if (!limitCheck.allowed) {
|
||||||
|
throw new BadRequestException(limitCheck.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, tokenPrefix } = generateTelegramStyleBotToken();
|
||||||
|
const tokenHash = hashBotToken(token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bot = await this.prisma.bot.create({
|
||||||
|
data: {
|
||||||
|
name: trimmedName,
|
||||||
|
username: normalizedUsername,
|
||||||
|
tokenHash,
|
||||||
|
tokenPrefix,
|
||||||
|
ownerId
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, displayName: true, username: true } },
|
||||||
|
_count: { select: { messages: true, chats: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.ensureBotSystemUser(bot.id);
|
||||||
|
await this.rateLimit.syncOwnerBotCount(ownerId, ownedCount + 1);
|
||||||
|
return { bot: this.toBotResponse(bot), token };
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException('Бот с таким username уже существует');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeToken(requesterId: string, botId: string, isSuperAdmin: boolean) {
|
||||||
|
const bot = await this.getManagedBot(requesterId, botId, isSuperAdmin);
|
||||||
|
const { token, tokenPrefix } = generateTelegramStyleBotToken();
|
||||||
|
const tokenHash = hashBotToken(token);
|
||||||
|
|
||||||
|
const updated = await this.prisma.bot.update({
|
||||||
|
where: { id: bot.id },
|
||||||
|
data: { tokenHash, tokenPrefix },
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, displayName: true, username: true } },
|
||||||
|
_count: { select: { messages: true, chats: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.rateLimit.invalidateTokenCache(bot.tokenHash);
|
||||||
|
return { bot: this.toBotResponse(updated), token };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setWebApp(requesterId: string, botId: string, webAppUrl: string | null | undefined, isSuperAdmin: boolean) {
|
||||||
|
const bot = await this.getManagedBot(requesterId, botId, isSuperAdmin);
|
||||||
|
const normalizedUrl = webAppUrl?.trim() || null;
|
||||||
|
if (normalizedUrl && !/^https?:\/\//i.test(normalizedUrl)) {
|
||||||
|
throw new BadRequestException('Mini App URL должен начинаться с http:// или https://');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.bot.update({
|
||||||
|
where: { id: bot.id },
|
||||||
|
data: { webAppUrl: normalizedUrl },
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, displayName: true, username: true } },
|
||||||
|
_count: { select: { messages: true, chats: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toBotResponse(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMyBots(ownerId: string) {
|
||||||
|
const bots = await this.prisma.bot.findMany({
|
||||||
|
where: { ownerId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, displayName: true, username: true } },
|
||||||
|
_count: { select: { messages: true, chats: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { bots: bots.map((bot) => this.toBotResponse(bot)), total: bots.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBot(requesterId: string, botId: string, isSuperAdmin: boolean) {
|
||||||
|
const bot = await this.getManagedBot(requesterId, botId, isSuperAdmin, true);
|
||||||
|
return this.toBotResponse(bot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBot(
|
||||||
|
requesterId: string,
|
||||||
|
botId: string,
|
||||||
|
payload: { name?: string; username?: string },
|
||||||
|
isSuperAdmin: boolean
|
||||||
|
) {
|
||||||
|
const bot = await this.getManagedBot(requesterId, botId, isSuperAdmin);
|
||||||
|
const data: { name?: string; username?: string } = {};
|
||||||
|
|
||||||
|
if (payload.name !== undefined) {
|
||||||
|
const trimmedName = payload.name.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
throw new BadRequestException('Укажите название бота');
|
||||||
|
}
|
||||||
|
data.name = trimmedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.username !== undefined) {
|
||||||
|
try {
|
||||||
|
data.username = assertValidBotUsername(payload.username);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('Username бота должен содержать 5–32 символа: латиница, цифры и _, без суффикса _bot');
|
||||||
|
}
|
||||||
|
if (normalizeBotUsername(payload.username) === normalizeBotUsername(bot.username.replace(/_bot$/, ''))) {
|
||||||
|
delete data.username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.bot.update({
|
||||||
|
where: { id: bot.id },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, displayName: true, username: true } },
|
||||||
|
_count: { select: { messages: true, chats: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this.toBotResponse(updated);
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException('Бот с таким username уже существует');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBot(requesterId: string, botId: string, isSuperAdmin: boolean) {
|
||||||
|
const bot = await this.getManagedBot(requesterId, botId, isSuperAdmin);
|
||||||
|
await this.prisma.bot.delete({ where: { id: bot.id } });
|
||||||
|
await this.rateLimit.invalidateTokenCache(bot.tokenHash);
|
||||||
|
const ownedCount = await this.prisma.bot.count({ where: { ownerId: bot.ownerId } });
|
||||||
|
await this.rateLimit.syncOwnerBotCount(bot.ownerId, ownedCount);
|
||||||
|
return { count: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAllBots(requesterId: string, isSuperAdmin: boolean, search?: string, page = 1, limit = 20) {
|
||||||
|
await this.assertManageAllBots(requesterId, isSuperAdmin);
|
||||||
|
const safePage = Math.max(page, 1);
|
||||||
|
const safeLimit = Math.min(Math.max(limit, 1), 100);
|
||||||
|
const where = search?.trim()
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: search.trim(), mode: 'insensitive' as const } },
|
||||||
|
{ username: { contains: normalizeBotUsername(search.trim()), mode: 'insensitive' as const } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const [bots, total] = await Promise.all([
|
||||||
|
this.prisma.bot.findMany({
|
||||||
|
where,
|
||||||
|
skip: (safePage - 1) * safeLimit,
|
||||||
|
take: safeLimit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, displayName: true, username: true } },
|
||||||
|
_count: { select: { messages: true, chats: true } }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
this.prisma.bot.count({ where })
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { bots: bots.map((bot) => this.toBotResponse(bot)), total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBotProfileByOwner(
|
||||||
|
ownerId: string,
|
||||||
|
botUsernameRaw: string,
|
||||||
|
patch: {
|
||||||
|
description?: string | null;
|
||||||
|
aboutText?: string | null;
|
||||||
|
botPicUrl?: string | null;
|
||||||
|
menuButton?: unknown;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const bot = await this.findOwnedBotByUsername(ownerId, botUsernameRaw);
|
||||||
|
return this.applyBotProfilePatch(bot.id, bot.tokenHash, bot.ownerId, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBotProfile(
|
||||||
|
requesterId: string,
|
||||||
|
botId: string,
|
||||||
|
patch: {
|
||||||
|
description?: string | null;
|
||||||
|
aboutText?: string | null;
|
||||||
|
botPicUrl?: string | null;
|
||||||
|
menuButton?: unknown;
|
||||||
|
},
|
||||||
|
isSuperAdmin: boolean
|
||||||
|
) {
|
||||||
|
const bot = await this.getManagedBot(requesterId, botId, isSuperAdmin);
|
||||||
|
return this.applyBotProfilePatch(bot.id, bot.tokenHash, bot.ownerId, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyBotProfilePatch(
|
||||||
|
botId: string,
|
||||||
|
tokenHash: string,
|
||||||
|
ownerId: string,
|
||||||
|
patch: {
|
||||||
|
description?: string | null;
|
||||||
|
aboutText?: string | null;
|
||||||
|
botPicUrl?: string | null;
|
||||||
|
menuButton?: unknown;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const data: {
|
||||||
|
description?: string | null;
|
||||||
|
aboutText?: string | null;
|
||||||
|
botPicUrl?: string | null;
|
||||||
|
menuButton?: unknown;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (patch.description !== undefined) {
|
||||||
|
const value = patch.description?.trim() || null;
|
||||||
|
if (value && value.length > 512) {
|
||||||
|
throw new BadRequestException('Описание бота не может быть длиннее 512 символов');
|
||||||
|
}
|
||||||
|
data.description = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.aboutText !== undefined) {
|
||||||
|
const value = patch.aboutText?.trim() || null;
|
||||||
|
if (value && value.length > 120) {
|
||||||
|
throw new BadRequestException('Краткое описание не может быть длиннее 120 символов');
|
||||||
|
}
|
||||||
|
data.aboutText = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.botPicUrl !== undefined) {
|
||||||
|
const value = patch.botPicUrl?.trim() || null;
|
||||||
|
if (value && !/^https?:\/\//i.test(value)) {
|
||||||
|
throw new BadRequestException('URL аватара должен начинаться с http:// или https://');
|
||||||
|
}
|
||||||
|
data.botPicUrl = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.menuButton !== undefined) {
|
||||||
|
const parsed = parseMenuButtonInput(patch.menuButton);
|
||||||
|
data.menuButton = menuButtonToJson(parsed ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.bot.update({
|
||||||
|
where: { id: botId },
|
||||||
|
data: data as never,
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, displayName: true, username: true } },
|
||||||
|
_count: { select: { messages: true, chats: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.rateLimit.invalidateTokenCache(tokenHash);
|
||||||
|
await this.publishBotProfileUpdated(ownerId, updated as BotRecord);
|
||||||
|
|
||||||
|
if (patch.menuButton !== undefined) {
|
||||||
|
await this.broadcastGlobalMenuButtonChange(updated as BotRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toBotResponse(updated as BotRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setBotActive(requesterId: string, botId: string, isActive: boolean, isSuperAdmin: boolean) {
|
||||||
|
await this.assertManageAllBots(requesterId, isSuperAdmin);
|
||||||
|
const bot = await this.prisma.bot.findUnique({ where: { id: botId } });
|
||||||
|
if (!bot) {
|
||||||
|
throw new NotFoundException('Бот не найден');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.bot.update({
|
||||||
|
where: { id: botId },
|
||||||
|
data: { isActive },
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, displayName: true, username: true } },
|
||||||
|
_count: { select: { messages: true, chats: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.rateLimit.invalidateTokenCache(updated.tokenHash);
|
||||||
|
return this.toBotResponse(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBotMetrics(requesterId: string, isSuperAdmin: boolean) {
|
||||||
|
await this.assertManageAllBots(requesterId, isSuperAdmin);
|
||||||
|
const [totalBots, activeBots, blockedBots, totalMessages, totalChats] = await Promise.all([
|
||||||
|
this.prisma.bot.count(),
|
||||||
|
this.prisma.bot.count({ where: { isActive: true } }),
|
||||||
|
this.prisma.bot.count({ where: { isActive: false } }),
|
||||||
|
this.prisma.botMessage.count(),
|
||||||
|
this.prisma.botChat.count()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { totalBots, activeBots, blockedBots, totalMessages, totalChats };
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateBotToken(token: string) {
|
||||||
|
const tokenHash = hashBotToken(token);
|
||||||
|
const cached = await this.rateLimit.getCachedValidatedBot(tokenHash);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bot = await this.prisma.bot.findUnique({
|
||||||
|
where: { tokenHash },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
username: true,
|
||||||
|
ownerId: true,
|
||||||
|
webAppUrl: true,
|
||||||
|
menuButton: true,
|
||||||
|
webhookUrl: true,
|
||||||
|
webhookSecretToken: true,
|
||||||
|
isActive: true,
|
||||||
|
isSystemBot: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bot || !bot.isActive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id: bot.id,
|
||||||
|
name: bot.name,
|
||||||
|
username: bot.username,
|
||||||
|
ownerId: bot.ownerId,
|
||||||
|
webAppUrl: bot.webAppUrl,
|
||||||
|
menuButton: bot.menuButton,
|
||||||
|
webhookUrl: bot.webhookUrl,
|
||||||
|
webhookSecretToken: bot.webhookSecretToken,
|
||||||
|
isActive: bot.isActive,
|
||||||
|
isSystemBot: bot.isSystemBot
|
||||||
|
};
|
||||||
|
await this.rateLimit.cacheValidatedBot(tokenHash, payload);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidateBotCacheById(botId: string) {
|
||||||
|
const bot = await this.prisma.bot.findUnique({ where: { id: botId }, select: { tokenHash: true } });
|
||||||
|
if (bot) {
|
||||||
|
await this.rateLimit.invalidateTokenCache(bot.tokenHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getManagedBot(requesterId: string, botId: string, isSuperAdmin: boolean, includeCounts = false) {
|
||||||
|
const bot = await this.prisma.bot.findUnique({
|
||||||
|
where: { id: botId },
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, displayName: true, username: true } },
|
||||||
|
...(includeCounts ? { _count: { select: { messages: true, chats: true } } } : {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!bot) {
|
||||||
|
throw new NotFoundException('Бот не найден');
|
||||||
|
}
|
||||||
|
if (!isSuperAdmin && bot.ownerId !== requesterId) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для управления этим ботом');
|
||||||
|
}
|
||||||
|
return bot as BotRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertManageAllBots(requesterId: string, isSuperAdmin: boolean) {
|
||||||
|
if (isSuperAdmin) return;
|
||||||
|
try {
|
||||||
|
await this.access.assertPermission(requesterId, false, 'bots.manage.all');
|
||||||
|
} catch {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для управления всеми ботами');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toBotResponse(bot: BotRecord) {
|
||||||
|
return {
|
||||||
|
id: bot.id,
|
||||||
|
name: bot.name,
|
||||||
|
username: bot.username,
|
||||||
|
tokenPrefix: bot.tokenPrefix,
|
||||||
|
ownerId: bot.ownerId,
|
||||||
|
description: bot.description ?? undefined,
|
||||||
|
aboutText: bot.aboutText ?? undefined,
|
||||||
|
botPicUrl: bot.botPicUrl ?? undefined,
|
||||||
|
menuButtonJson: bot.menuButton ? JSON.stringify(bot.menuButton) : undefined,
|
||||||
|
webAppUrl: bot.webAppUrl ?? undefined,
|
||||||
|
isActive: bot.isActive,
|
||||||
|
isSystemBot: bot.isSystemBot,
|
||||||
|
createdAt: bot.createdAt.toISOString(),
|
||||||
|
updatedAt: bot.updatedAt.toISOString(),
|
||||||
|
owner: bot.owner
|
||||||
|
? {
|
||||||
|
id: bot.owner.id,
|
||||||
|
displayName: bot.owner.displayName,
|
||||||
|
username: bot.owner.username ?? undefined
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
messageCount: bot._count?.messages ?? 0,
|
||||||
|
chatCount: bot._count?.chats ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOwnedBotByUsername(ownerId: string, botUsernameRaw: string) {
|
||||||
|
const normalized = normalizeBotUsername(botUsernameRaw.replace(/^@/, ''));
|
||||||
|
const bot = await this.prisma.bot.findFirst({
|
||||||
|
where: {
|
||||||
|
ownerId,
|
||||||
|
OR: [{ username: normalized }, { username: `${normalized.replace(/_bot$/i, '')}_bot` }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!bot) {
|
||||||
|
throw new NotFoundException('Бот не найден или не принадлежит вам');
|
||||||
|
}
|
||||||
|
return bot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async publishBotProfileUpdated(ownerId: string, bot: BotRecord) {
|
||||||
|
await this.notifications.publishRealtime(ownerId, 'bot_profile_updated', bot.name, '', {
|
||||||
|
botId: bot.id,
|
||||||
|
botUsername: bot.username,
|
||||||
|
description: bot.description ?? null,
|
||||||
|
aboutText: bot.aboutText ?? null,
|
||||||
|
botPicUrl: bot.botPicUrl ?? null,
|
||||||
|
menuButtonJson: bot.menuButton ? JSON.stringify(bot.menuButton) : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async broadcastGlobalMenuButtonChange(bot: BotRecord) {
|
||||||
|
const chats = await this.prisma.botChat.findMany({
|
||||||
|
where: { botId: bot.id, recipientUserId: { not: null } },
|
||||||
|
include: { menuButton: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
chats.map(async (chat) => {
|
||||||
|
if (!chat.recipientUserId) return;
|
||||||
|
const resolved = resolveComposerMenuButton({
|
||||||
|
chatOverride: chat.menuButton?.menuButton ?? null,
|
||||||
|
globalMenuButton: bot.menuButton,
|
||||||
|
bot
|
||||||
|
});
|
||||||
|
await this.notifications.publishRealtime(chat.recipientUserId, 'bot_menu_button_updated', bot.name, '', {
|
||||||
|
botId: bot.id,
|
||||||
|
botUsername: bot.username,
|
||||||
|
composerWebAppUrl: resolved.composerWebAppUrl ?? null,
|
||||||
|
composerMenuButtonJson: resolved.menuButton ? JSON.stringify(resolved.menuButton) : null,
|
||||||
|
buttonText: resolved.buttonText ?? null
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUniqueViolation(error: unknown) {
|
||||||
|
return typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'P2002';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import amqp from 'amqplib';
|
||||||
|
|
||||||
|
export const BOT_INBOUND_QUEUE = 'chat.message.bot_inbound';
|
||||||
|
|
||||||
|
export interface BotInboundQueueMessage {
|
||||||
|
eventType: 'message' | 'callback_query';
|
||||||
|
botId: string;
|
||||||
|
senderUserId: string;
|
||||||
|
text?: string | null;
|
||||||
|
botChatId: string;
|
||||||
|
telegramChatId: string;
|
||||||
|
telegramMessageId: number;
|
||||||
|
updateId: number;
|
||||||
|
createdAt: string;
|
||||||
|
callbackQueryId?: string;
|
||||||
|
callbackData?: string;
|
||||||
|
sourceBotMessageId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BotInboundPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(BotInboundPublisherService.name);
|
||||||
|
private connection: { createChannel: () => Promise<amqp.Channel>; close: () => Promise<void> } | null = null;
|
||||||
|
private channel: amqp.Channel | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
try {
|
||||||
|
await this.channel?.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.connection?.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connect() {
|
||||||
|
const url = this.config.get<string>('RABBITMQ_URL', 'amqp://lendry:lendry_password@localhost:5672/');
|
||||||
|
try {
|
||||||
|
this.connection = (await amqp.connect(url)) as {
|
||||||
|
createChannel: () => Promise<amqp.Channel>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
};
|
||||||
|
this.channel = await this.connection.createChannel();
|
||||||
|
await this.channel.assertQueue(BOT_INBOUND_QUEUE, { durable: true });
|
||||||
|
this.logger.log(`Очередь ${BOT_INBOUND_QUEUE} готова к публикации`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`RabbitMQ недоступен для bot inbound: ${error instanceof Error ? error.message : error}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(message: BotInboundQueueMessage) {
|
||||||
|
if (!this.channel) {
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
if (!this.channel) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.channel.sendToQueue(BOT_INBOUND_QUEUE, Buffer.from(JSON.stringify(message)), { persistent: true });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Не удалось опубликовать bot inbound: ${error instanceof Error ? error.message : error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
213
apps/sso-core/src/domain/bot/bot-inbound.service.ts
Normal file
213
apps/sso-core/src/domain/bot/bot-inbound.service.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../../infra/prisma.service';
|
||||||
|
import { BotDeliveryService } from './bot-delivery.service';
|
||||||
|
import { BotFatherAssistantService } from './bot-father-assistant.service';
|
||||||
|
import { BotFatherSeedService } from './bot-father-seed.service';
|
||||||
|
import { BotInboundPublisherService } from './bot-inbound-publisher.service';
|
||||||
|
import { deriveTelegramChatId, normalizeBotUsername } from './bot-token.util';
|
||||||
|
import { assertHumanAccount } from '../system-account.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BotInboundService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly publisher: BotInboundPublisherService,
|
||||||
|
private readonly delivery: BotDeliveryService,
|
||||||
|
private readonly seed: BotFatherSeedService,
|
||||||
|
private readonly assistant: BotFatherAssistantService
|
||||||
|
) {}
|
||||||
|
async submitUserMessage(senderUserId: string, botRef: string, text: string) {
|
||||||
|
await this.assertHumanSender(senderUserId);
|
||||||
|
|
||||||
|
const trimmedText = text.trim();
|
||||||
|
if (!trimmedText) {
|
||||||
|
throw new BadRequestException('Текст сообщения не может быть пустым');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bot = await this.findBot(botRef);
|
||||||
|
if (!bot || !bot.isActive) {
|
||||||
|
throw new NotFoundException('Бот не найден или заблокирован');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await this.ensureBotChat(bot.id, senderUserId);
|
||||||
|
const event = await this.createMessageEvent(bot.id, chat.id, senderUserId, chat.telegramChatId, trimmedText);
|
||||||
|
await this.dispatchEvent(event);
|
||||||
|
if (bot.isSystemBot) {
|
||||||
|
void this.assistant.handleUserMessage(senderUserId, trimmedText).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateId: event.updateId,
|
||||||
|
messageId: event.telegramMessageId,
|
||||||
|
chatId: event.telegramChatId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitCallbackQuery(senderUserId: string, botRef: string, messageId: number, callbackData: string) {
|
||||||
|
await this.assertHumanSender(senderUserId);
|
||||||
|
|
||||||
|
const trimmedData = callbackData.trim();
|
||||||
|
if (!trimmedData) {
|
||||||
|
throw new BadRequestException('callbackData не может быть пустым');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(messageId) || messageId <= 0) {
|
||||||
|
throw new BadRequestException('messageId некорректен');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bot = await this.findBot(botRef);
|
||||||
|
if (!bot || !bot.isActive) {
|
||||||
|
throw new NotFoundException('Бот не найден или заблокирован');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await this.ensureBotChat(bot.id, senderUserId);
|
||||||
|
const sourceMessage = await this.prisma.botMessage.findFirst({
|
||||||
|
where: {
|
||||||
|
botId: bot.id,
|
||||||
|
botChatId: chat.id,
|
||||||
|
telegramMsgId: messageId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!sourceMessage) {
|
||||||
|
throw new NotFoundException('Сообщение бота для callback не найдено');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateId = await this.allocateUpdateId(bot.id);
|
||||||
|
const event = {
|
||||||
|
eventType: 'callback_query' as const,
|
||||||
|
botId: bot.id,
|
||||||
|
senderUserId,
|
||||||
|
botChatId: chat.id,
|
||||||
|
telegramChatId: chat.telegramChatId.toString(),
|
||||||
|
telegramMessageId: messageId,
|
||||||
|
updateId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
callbackQueryId: randomUUID(),
|
||||||
|
callbackData: trimmedData,
|
||||||
|
sourceBotMessageId: messageId
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.dispatchEvent(event);
|
||||||
|
if (bot.isSystemBot) {
|
||||||
|
void this.assistant.handleCallbackQuery(senderUserId, trimmedData).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateId: event.updateId,
|
||||||
|
callbackQueryId: event.callbackQueryId,
|
||||||
|
messageId: event.telegramMessageId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createMessageEvent(
|
||||||
|
botId: string,
|
||||||
|
botChatId: string,
|
||||||
|
senderUserId: string,
|
||||||
|
telegramChatId: bigint,
|
||||||
|
text: string
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const [updatedBot, updatedChat] = await Promise.all([
|
||||||
|
tx.bot.update({
|
||||||
|
where: { id: botId },
|
||||||
|
data: { nextUpdateId: { increment: 1 } },
|
||||||
|
select: { nextUpdateId: true }
|
||||||
|
}),
|
||||||
|
tx.botChat.update({
|
||||||
|
where: { id: botChatId },
|
||||||
|
data: { nextInboundMessageId: { increment: 1 } },
|
||||||
|
select: { nextInboundMessageId: true }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const updateId = updatedBot.nextUpdateId - 1;
|
||||||
|
const telegramMessageId = updatedChat.nextInboundMessageId - 1;
|
||||||
|
|
||||||
|
await tx.botInboundMessage.create({
|
||||||
|
data: {
|
||||||
|
botId,
|
||||||
|
botChatId,
|
||||||
|
senderUserId,
|
||||||
|
telegramMsgId: telegramMessageId,
|
||||||
|
text
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventType: 'message' as const,
|
||||||
|
botId,
|
||||||
|
senderUserId,
|
||||||
|
text,
|
||||||
|
botChatId,
|
||||||
|
telegramChatId: telegramChatId.toString(),
|
||||||
|
telegramMessageId,
|
||||||
|
updateId,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async allocateUpdateId(botId: string) {
|
||||||
|
const updated = await this.prisma.bot.update({
|
||||||
|
where: { id: botId },
|
||||||
|
data: { nextUpdateId: { increment: 1 } },
|
||||||
|
select: { nextUpdateId: true }
|
||||||
|
});
|
||||||
|
return updated.nextUpdateId - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dispatchEvent(event: Parameters<BotInboundPublisherService['publish']>[0]) {
|
||||||
|
const published = await this.publisher.publish(event);
|
||||||
|
if (!published) {
|
||||||
|
await this.delivery.deliverInboundEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertHumanSender(userId: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { isSystemAccount: true, linkedBotId: true, deletedAt: true, status: true }
|
||||||
|
});
|
||||||
|
if (!user || user.deletedAt || user.status !== 'ACTIVE') {
|
||||||
|
throw new NotFoundException('Отправитель не найден');
|
||||||
|
}
|
||||||
|
assertHumanAccount(user, { message: 'Системные учётные записи не могут взаимодействовать с ботами' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findBot(botRef: string) {
|
||||||
|
const normalized = normalizeBotUsername(botRef.replace(/_bot$/i, ''));
|
||||||
|
return this.prisma.bot.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ id: botRef }, { username: botRef }, { username: `${normalized}_bot` }, { username: normalized }]
|
||||||
|
},
|
||||||
|
select: { id: true, isActive: true, isSystemBot: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureBotChat(botId: string, recipientUserId: string) {
|
||||||
|
const existing = await this.prisma.botChat.findUnique({
|
||||||
|
where: { botId_recipientUserId: { botId, recipientUserId } }
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const telegramChatId = deriveTelegramChatId(`${botId}:${recipientUserId}`);
|
||||||
|
try {
|
||||||
|
return await this.prisma.botChat.create({
|
||||||
|
data: {
|
||||||
|
botId,
|
||||||
|
recipientUserId,
|
||||||
|
telegramChatId,
|
||||||
|
chatType: 'private'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
const fallback = await this.prisma.botChat.findUnique({
|
||||||
|
where: { botId_recipientUserId: { botId, recipientUserId } }
|
||||||
|
});
|
||||||
|
if (!fallback) {
|
||||||
|
throw new NotFoundException('Не удалось создать чат с ботом');
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
162
apps/sso-core/src/domain/bot/bot-menu-button.util.ts
Normal file
162
apps/sso-core/src/domain/bot/bot-menu-button.util.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { BOTFATHER_BOT_USERNAME, buildBotCreateWebAppUrl, isBotManageWebAppUrl } from './bot-father.constants';
|
||||||
|
|
||||||
|
export type TelegramMenuButtonDefault = { type: 'default' };
|
||||||
|
export type TelegramMenuButtonCommands = { type: 'commands' };
|
||||||
|
export type TelegramMenuButtonWebApp = {
|
||||||
|
type: 'web_app';
|
||||||
|
text: string;
|
||||||
|
web_app: { url: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TelegramMenuButton =
|
||||||
|
| TelegramMenuButtonDefault
|
||||||
|
| TelegramMenuButtonCommands
|
||||||
|
| TelegramMenuButtonWebApp;
|
||||||
|
|
||||||
|
export type ResolvedComposerMenuButton = {
|
||||||
|
menuButton: TelegramMenuButtonWebApp | null;
|
||||||
|
composerWebAppUrl?: string;
|
||||||
|
buttonText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceJsonValue(raw: unknown): unknown {
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWebAppButton(value: Record<string, unknown>): TelegramMenuButtonWebApp | null {
|
||||||
|
const text = typeof value.text === 'string' ? value.text.trim() : '';
|
||||||
|
const webApp = isRecord(value.web_app) ? value.web_app : null;
|
||||||
|
const url = typeof webApp?.url === 'string' ? webApp.url.trim() : '';
|
||||||
|
if (!text || !url || !/^https?:\/\//i.test(url)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { type: 'web_app', text, web_app: { url } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMenuButtonInput(raw: unknown): TelegramMenuButton | null | undefined {
|
||||||
|
if (raw === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coerced = coerceJsonValue(raw);
|
||||||
|
if (typeof coerced === 'string') {
|
||||||
|
const trimmed = coerced.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) {
|
||||||
|
return { type: 'web_app', text: 'App', web_app: { url: trimmed } };
|
||||||
|
}
|
||||||
|
const pipeParts = trimmed.split('|');
|
||||||
|
if (pipeParts.length === 2 && /^https?:\/\//i.test(pipeParts[0]!.trim())) {
|
||||||
|
return {
|
||||||
|
type: 'web_app',
|
||||||
|
text: pipeParts[1]!.trim() || 'App',
|
||||||
|
web_app: { url: pipeParts[0]!.trim() }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(coerced)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = typeof coerced.type === 'string' ? coerced.type.trim().toLowerCase() : '';
|
||||||
|
if (!type || type === 'default') {
|
||||||
|
return { type: 'default' };
|
||||||
|
}
|
||||||
|
if (type === 'commands') {
|
||||||
|
return { type: 'commands' };
|
||||||
|
}
|
||||||
|
if (type === 'web_app') {
|
||||||
|
return normalizeWebAppButton(coerced);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function menuButtonToJson(menuButton: TelegramMenuButton | null | undefined): Record<string, unknown> | null {
|
||||||
|
if (!menuButton || menuButton.type === 'default') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (menuButton.type === 'commands') {
|
||||||
|
return { type: 'commands' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'web_app',
|
||||||
|
text: menuButton.text,
|
||||||
|
web_app: { url: menuButton.web_app.url }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractWebAppUrl(menuButton: unknown): string | undefined {
|
||||||
|
const parsed = parseMenuButtonInput(menuButton);
|
||||||
|
if (!parsed || parsed.type !== 'web_app') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const url = parsed.web_app.url.trim();
|
||||||
|
if (!url || isBotManageWebAppUrl(url)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveComposerMenuButton(options: {
|
||||||
|
chatOverride?: unknown;
|
||||||
|
globalMenuButton?: unknown;
|
||||||
|
bot: { isSystemBot: boolean; username: string; webAppUrl?: string | null };
|
||||||
|
}): ResolvedComposerMenuButton {
|
||||||
|
const chatParsed = parseMenuButtonInput(options.chatOverride);
|
||||||
|
if (chatParsed && chatParsed.type === 'web_app') {
|
||||||
|
const url = extractWebAppUrl(chatParsed);
|
||||||
|
if (url) {
|
||||||
|
return { menuButton: chatParsed, composerWebAppUrl: url, buttonText: chatParsed.text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalParsed = parseMenuButtonInput(options.globalMenuButton);
|
||||||
|
if (globalParsed && globalParsed.type === 'web_app') {
|
||||||
|
const url = extractWebAppUrl(globalParsed);
|
||||||
|
if (url) {
|
||||||
|
return { menuButton: globalParsed, composerWebAppUrl: url, buttonText: globalParsed.text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.bot.isSystemBot || options.bot.username === BOTFATHER_BOT_USERNAME) {
|
||||||
|
const url = buildBotCreateWebAppUrl();
|
||||||
|
return {
|
||||||
|
menuButton: { type: 'web_app', text: 'Создать бота', web_app: { url } },
|
||||||
|
composerWebAppUrl: url,
|
||||||
|
buttonText: 'Создать бота'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyUrl = options.bot.webAppUrl?.trim();
|
||||||
|
if (legacyUrl && !isBotManageWebAppUrl(legacyUrl)) {
|
||||||
|
return {
|
||||||
|
menuButton: { type: 'web_app', text: 'App', web_app: { url: legacyUrl } },
|
||||||
|
composerWebAppUrl: legacyUrl,
|
||||||
|
buttonText: 'App'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { menuButton: null };
|
||||||
|
}
|
||||||
78
apps/sso-core/src/domain/bot/bot-message-consumer.service.ts
Normal file
78
apps/sso-core/src/domain/bot/bot-message-consumer.service.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import amqp from 'amqplib';
|
||||||
|
import { BOT_INBOUND_QUEUE, BotInboundQueueMessage } from './bot-inbound-publisher.service';
|
||||||
|
import { BotDeliveryService } from './bot-delivery.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BotMessageConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(BotMessageConsumerService.name);
|
||||||
|
private connection: { createChannel: () => Promise<amqp.Channel>; close: () => Promise<void> } | null = null;
|
||||||
|
private channel: amqp.Channel | null = null;
|
||||||
|
private consumerTag: string | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly delivery: BotDeliveryService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
try {
|
||||||
|
if (this.channel && this.consumerTag) {
|
||||||
|
await this.channel.cancel(this.consumerTag);
|
||||||
|
}
|
||||||
|
await this.channel?.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.connection?.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connect() {
|
||||||
|
const url = this.config.get<string>('RABBITMQ_URL', 'amqp://lendry:lendry_password@localhost:5672/');
|
||||||
|
try {
|
||||||
|
this.connection = (await amqp.connect(url)) as {
|
||||||
|
createChannel: () => Promise<amqp.Channel>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
};
|
||||||
|
this.channel = await this.connection.createChannel();
|
||||||
|
await this.channel.assertQueue(BOT_INBOUND_QUEUE, { durable: true });
|
||||||
|
await this.channel.prefetch(10);
|
||||||
|
|
||||||
|
const consumeResult = await this.channel.consume(BOT_INBOUND_QUEUE, (message) => {
|
||||||
|
if (!message) return;
|
||||||
|
void this.handleMessage(message);
|
||||||
|
});
|
||||||
|
this.consumerTag = consumeResult.consumerTag;
|
||||||
|
this.logger.log(`Consumer ${BOT_INBOUND_QUEUE} запущен`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Не удалось запустить consumer ${BOT_INBOUND_QUEUE}: ${error instanceof Error ? error.message : error}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMessage(message: amqp.Message) {
|
||||||
|
if (!this.channel) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(message.content.toString()) as BotInboundQueueMessage;
|
||||||
|
await this.delivery.deliverInboundEvent(payload);
|
||||||
|
this.channel.ack(message);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Ошибка обработки bot inbound: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined
|
||||||
|
);
|
||||||
|
this.channel.nack(message, false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
apps/sso-core/src/domain/bot/bot-rate-limit.service.ts
Normal file
55
apps/sso-core/src/domain/bot/bot-rate-limit.service.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { RedisService } from '../../infra/redis.service';
|
||||||
|
import { SettingsService } from '../settings.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BotRateLimitService {
|
||||||
|
constructor(
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
private readonly settings: SettingsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async assertCanCreateBot(ownerId: string) {
|
||||||
|
const maxBots = await this.settings.getNumber('BOT_MAX_BOTS_PER_USER', 5);
|
||||||
|
const key = `bot:create:count:${ownerId}`;
|
||||||
|
const cached = await this.redis.client.get(key);
|
||||||
|
if (cached) {
|
||||||
|
const count = Number(cached);
|
||||||
|
if (Number.isFinite(count) && count >= maxBots) {
|
||||||
|
return { allowed: false as const, reason: `Достигнут лимит ботов (${maxBots})` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { allowed: true as const, maxBots };
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncOwnerBotCount(ownerId: string, count: number) {
|
||||||
|
await this.redis.client.set(`bot:create:count:${ownerId}`, String(count), 'EX', 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertBotApiRateLimit(tokenHash: string) {
|
||||||
|
const limit = await this.settings.getNumber('BOT_API_RATE_LIMIT_PER_SECOND', 30);
|
||||||
|
const key = `bot:api:rate:${tokenHash}`;
|
||||||
|
const count = await this.redis.client.incr(key);
|
||||||
|
if (count === 1) {
|
||||||
|
await this.redis.client.expire(key, 1);
|
||||||
|
}
|
||||||
|
if (count > limit) {
|
||||||
|
const retryAfter = Math.max(await this.redis.client.ttl(key), 1);
|
||||||
|
return { allowed: false as const, retryAfter };
|
||||||
|
}
|
||||||
|
return { allowed: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
async cacheValidatedBot(tokenHash: string, payload: Record<string, unknown>) {
|
||||||
|
await this.redis.client.set(`bot:token:${tokenHash}`, JSON.stringify(payload), 'EX', 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCachedValidatedBot(tokenHash: string) {
|
||||||
|
const cached = await this.redis.client.get(`bot:token:${tokenHash}`);
|
||||||
|
return cached ? (JSON.parse(cached) as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidateTokenCache(tokenHash: string) {
|
||||||
|
await this.redis.client.del(`bot:token:${tokenHash}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user