global update and global fix
This commit is contained in:
@@ -35,6 +35,20 @@ import { MessagingService } from './infra/messaging.service';
|
||||
import { SmsService } from './infra/sms.service';
|
||||
import { TotpService } from './domain/totp.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({
|
||||
imports: [
|
||||
@@ -74,7 +88,21 @@ import { MaintenanceSchedulerService } from './domain/maintenance-scheduler.serv
|
||||
LdapClientService,
|
||||
MessagingService,
|
||||
SmsService,
|
||||
MaintenanceSchedulerService
|
||||
MaintenanceSchedulerService,
|
||||
BotFatherService,
|
||||
BotApiService,
|
||||
BotRateLimitService,
|
||||
WebAppCryptoService,
|
||||
TelegramSerializerService,
|
||||
BotUpdateQueueService,
|
||||
BotWebhookDispatcherService,
|
||||
BotInboundPublisherService,
|
||||
BotDeliveryService,
|
||||
BotMessageConsumerService,
|
||||
BotInboundService,
|
||||
BotCallbackRegistryService,
|
||||
BotFatherSeedService,
|
||||
BotFatherAssistantService
|
||||
]
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface UserAccessContext {
|
||||
canManageSettings: boolean;
|
||||
canViewUserDocuments: boolean;
|
||||
canVerifyUsers: boolean;
|
||||
canManageBots: boolean;
|
||||
}
|
||||
|
||||
function hasAnyPermission(permissions: string[], ...slugs: string[]) {
|
||||
@@ -42,7 +43,8 @@ export class AccessService {
|
||||
'settings.manage',
|
||||
'rbac.manage',
|
||||
'documents.view_others',
|
||||
'users.verify'
|
||||
'users.verify',
|
||||
'bots.manage.all'
|
||||
],
|
||||
canAccessAdmin: true,
|
||||
canManageRoles: true,
|
||||
@@ -54,7 +56,8 @@ export class AccessService {
|
||||
canViewUsers: true,
|
||||
canManageSettings: true,
|
||||
canViewUserDocuments: true,
|
||||
canVerifyUsers: true
|
||||
canVerifyUsers: true,
|
||||
canManageBots: true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,7 +105,8 @@ export class AccessService {
|
||||
canViewUsers,
|
||||
canManageSettings: permissions.includes('settings.manage'),
|
||||
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: 'settings.manage', name: 'Управление настройками', description: 'Изменение глобальных SystemSetting' },
|
||||
{ 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;
|
||||
|
||||
const ROLES = [
|
||||
@@ -33,7 +34,7 @@ const ROLES = [
|
||||
slug: 'admin',
|
||||
name: 'Администратор',
|
||||
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,
|
||||
isDefault: false
|
||||
},
|
||||
|
||||
@@ -23,6 +23,9 @@ import { ChatService } from './chat.service';
|
||||
import { PresenceService } from './presence.service';
|
||||
import { TotpService } from './totp.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()
|
||||
@UseFilters(new GrpcExceptionFilter())
|
||||
@@ -47,7 +50,10 @@ export class AuthGrpcController {
|
||||
private readonly chat: ChatService,
|
||||
private readonly presence: PresenceService,
|
||||
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')
|
||||
@@ -634,6 +640,16 @@ export class AuthGrpcController {
|
||||
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')
|
||||
createDocument(command: { userId: string; type: string; number: string; issuedAt?: string; expiresAt?: string; metadataJson?: string }) {
|
||||
return this.documents.create(command);
|
||||
@@ -875,6 +891,20 @@ export class AuthGrpcController {
|
||||
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')
|
||||
listNotifications(command: { userId: string; limit?: number; unreadOnly?: boolean }) {
|
||||
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 ?? []);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'CreateE2ERoom')
|
||||
createE2EChatRoom(command: { userId: string; groupId: string; peerUserId: string }) {
|
||||
return this.chat.createE2ERoom(command.userId, command.groupId, command.peerUserId);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'UpdateRoomSettings')
|
||||
updateChatRoomSettings(command: { userId: string; roomId: string; name?: string; notificationsMuted?: boolean }) {
|
||||
return this.chat.updateRoomSettings(command.userId, command.roomId, command.name, command.notificationsMuted);
|
||||
@@ -946,6 +981,7 @@ export class AuthGrpcController {
|
||||
mimeType?: string;
|
||||
metadataJson?: string;
|
||||
poll?: { question: string; options: string[]; allowsMultiple?: boolean; isAnonymous?: boolean };
|
||||
isEncrypted?: boolean;
|
||||
}) {
|
||||
return this.chat.sendMessage(
|
||||
command.userId,
|
||||
@@ -956,7 +992,8 @@ export class AuthGrpcController {
|
||||
command.storageKey,
|
||||
command.mimeType,
|
||||
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);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'DeleteRoom')
|
||||
deleteChatRoom(command: { userId: string; roomId: string }) {
|
||||
return this.chat.deleteRoom(command.userId, command.roomId);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'EditMessage')
|
||||
editChatMessage(command: { userId: string; messageId: string; content: string }) {
|
||||
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);
|
||||
}
|
||||
|
||||
@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')
|
||||
markChatRoomRead(command: { userId: string; roomId: string; lastMessageId?: string }) {
|
||||
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 }) {
|
||||
return {
|
||||
id: session.id,
|
||||
|
||||
@@ -17,6 +17,11 @@ import { canonicalPhoneE164, phoneLookupVariants } from '../infra/phone.util';
|
||||
import { TotpService } from './totp.service';
|
||||
import { RbacService } from './rbac.service';
|
||||
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 TOTP_LOGIN_CHALLENGE_TTL_SECONDS = 5 * 60;
|
||||
@@ -44,6 +49,8 @@ export class AuthService {
|
||||
throw new BadRequestException('Укажите почту или телефон');
|
||||
}
|
||||
|
||||
assertNotReservedUsername(command.username);
|
||||
|
||||
const passwordHash = command.password ? await bcrypt.hash(command.password, 12) : null;
|
||||
const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000);
|
||||
|
||||
@@ -86,7 +93,8 @@ export class AuthService {
|
||||
where: {
|
||||
OR: [{ email: command.login }, { phone: command.login }, { username: command.login }],
|
||||
deletedAt: null,
|
||||
status: UserStatus.ACTIVE
|
||||
status: UserStatus.ACTIVE,
|
||||
...HUMAN_USER_WHERE
|
||||
},
|
||||
include: { pinCode: true }
|
||||
});
|
||||
@@ -95,6 +103,8 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Неверный логин или пароль');
|
||||
}
|
||||
|
||||
assertHumanAccount(user, { asAuth: true });
|
||||
|
||||
if (!user.passwordHash) {
|
||||
throw new UnauthorizedException('Для этого аккаунта используйте вход по коду');
|
||||
}
|
||||
@@ -115,6 +125,7 @@ export class AuthService {
|
||||
if (!user || user.status !== UserStatus.ACTIVE) {
|
||||
throw new UnauthorizedException('Пользователь не найден');
|
||||
}
|
||||
assertHumanAccount(user, { asAuth: true });
|
||||
if (!(await this.totp.isEnabledForUser(user.id))) {
|
||||
throw new BadRequestException('Для этого аккаунта не настроен аутентификатор');
|
||||
}
|
||||
@@ -172,13 +183,15 @@ export class AuthService {
|
||||
await this.redis.client.del(attemptKey);
|
||||
|
||||
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 }
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
||||
}
|
||||
|
||||
assertHumanAccount(user, { asAuth: true });
|
||||
|
||||
const auth = await this.createAuthTokens(user, challenge.deviceId, challenge);
|
||||
await this.writeSignInEvent(user.id, true, challenge.signIn, challenge.deviceId);
|
||||
return auth;
|
||||
@@ -391,6 +404,7 @@ export class AuthService {
|
||||
? await this.findUserByTempPasswordToken(command.tempAuthToken)
|
||||
: await this.findUserByRecipient(command.login);
|
||||
if (!user?.passwordHash || user.status !== UserStatus.ACTIVE) throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
||||
assertHumanAccount(user, { asAuth: true });
|
||||
const matches = await bcrypt.compare(command.password, user.passwordHash);
|
||||
if (!matches) {
|
||||
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'>) {
|
||||
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 }
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
||||
}
|
||||
assertHumanAccount(user, { asAuth: true });
|
||||
const device = await this.upsertDevice(user.id, {
|
||||
fingerprint: command.fingerprint,
|
||||
deviceName: command.deviceName,
|
||||
@@ -575,6 +590,8 @@ export class AuthService {
|
||||
deviceId: string,
|
||||
options?: { skipTotp?: boolean }
|
||||
): Promise<AuthTokens> {
|
||||
assertHumanAccount(user, { asAuth: true });
|
||||
|
||||
if (!options?.skipTotp && (await this.totp.isEnabledForUser(user.id))) {
|
||||
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> {
|
||||
assertHumanAccount(user, { asAuth: true });
|
||||
|
||||
const pinVerified = !user.pinCode?.isEnabled;
|
||||
const refreshToken = randomBytes(48).toString('base64url');
|
||||
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
|
||||
@@ -700,14 +719,14 @@ export class AuthService {
|
||||
private async findUserByRecipient(recipient: string) {
|
||||
if (recipient.includes('@')) {
|
||||
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 }
|
||||
});
|
||||
}
|
||||
|
||||
const variants = phoneLookupVariants(recipient);
|
||||
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 }
|
||||
});
|
||||
}
|
||||
@@ -722,6 +741,7 @@ export class AuthService {
|
||||
if (!user || user.deletedAt || user.status !== UserStatus.ACTIVE) {
|
||||
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
||||
}
|
||||
assertHumanAccount(user, { asAuth: true });
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -763,10 +783,12 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
||||
}
|
||||
|
||||
assertHumanAccount(user, { asAuth: true });
|
||||
|
||||
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);
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -795,11 +817,14 @@ export class AuthService {
|
||||
canManageSettings: access.canManageSettings,
|
||||
canViewUsers: access.canViewUsers,
|
||||
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(
|
||||
{
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
44
apps/sso-core/src/domain/bot/bot-token.util.ts
Normal file
44
apps/sso-core/src/domain/bot/bot-token.util.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
const BOT_USERNAME_PATTERN = /^[a-z][a-z0-9_]{4,31}$/;
|
||||
|
||||
export function normalizeBotUsername(username: string) {
|
||||
return username.trim().toLowerCase().replace(/[^a-z0-9_]/g, '');
|
||||
}
|
||||
|
||||
export function assertValidBotUsername(username: string) {
|
||||
const normalized = normalizeBotUsername(username);
|
||||
if (!BOT_USERNAME_PATTERN.test(normalized)) {
|
||||
throw new Error('INVALID_BOT_USERNAME');
|
||||
}
|
||||
if (normalized.endsWith('_bot')) {
|
||||
throw new Error('INVALID_BOT_USERNAME');
|
||||
}
|
||||
return `${normalized}_bot`;
|
||||
}
|
||||
|
||||
export function generateTelegramStyleBotToken() {
|
||||
const botId = randomBytes(4).readUInt32BE(0) % 900_000_000 + 100_000_000;
|
||||
const secret = randomBytes(32)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
const token = `${botId}:${secret}`;
|
||||
return { token, tokenPrefix: `${botId}:${secret.slice(0, 6)}…` };
|
||||
}
|
||||
|
||||
export function hashBotToken(token: string) {
|
||||
return createHash('sha256').update(token.trim()).digest('hex');
|
||||
}
|
||||
|
||||
export function deriveTelegramChatId(source: string) {
|
||||
const hash = createHash('sha256').update(source).digest();
|
||||
const value = hash.readBigInt64BE(0);
|
||||
const positive = value < 0n ? -value : value;
|
||||
return positive % 9_000_000_000_000n + 1_000_000_000_000n;
|
||||
}
|
||||
|
||||
export function deriveTelegramUserId(userId: string) {
|
||||
return Number(deriveTelegramChatId(`user:${userId}`));
|
||||
}
|
||||
146
apps/sso-core/src/domain/bot/bot-update-queue.service.ts
Normal file
146
apps/sso-core/src/domain/bot/bot-update-queue.service.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { RedisService } from '../../infra/redis.service';
|
||||
|
||||
export type StoredTelegramUpdate = Record<string, unknown> & { update_id: number };
|
||||
|
||||
@Injectable()
|
||||
export class BotUpdateQueueService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(BotUpdateQueueService.name);
|
||||
private readonly emitter = new EventEmitter();
|
||||
private subscriberReady = false;
|
||||
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
const subscriber = this.redis.client.duplicate();
|
||||
await subscriber.connect();
|
||||
await subscriber.psubscribe('bot:updates:notify:*');
|
||||
subscriber.on('pmessage', (_pattern, channel) => {
|
||||
const botId = channel.replace('bot:updates:notify:', '');
|
||||
if (botId) {
|
||||
this.emitter.emit(this.waitKey(botId));
|
||||
}
|
||||
});
|
||||
this.subscriberReady = true;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Redis pub/sub для long polling недоступен: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.emitter.removeAllListeners();
|
||||
}
|
||||
|
||||
private listKey(botId: string) {
|
||||
return `bot:updates:${botId}`;
|
||||
}
|
||||
|
||||
private waitKey(botId: string) {
|
||||
return `bot:updates:wait:${botId}`;
|
||||
}
|
||||
|
||||
async enqueue(botId: string, update: StoredTelegramUpdate) {
|
||||
const serialized = JSON.stringify(update);
|
||||
await this.redis.client.rpush(this.listKey(botId), serialized);
|
||||
await this.redis.client.publish(`bot:updates:notify:${botId}`, '1');
|
||||
this.emitter.emit(this.waitKey(botId));
|
||||
}
|
||||
|
||||
async pendingCount(botId: string) {
|
||||
return this.redis.client.llen(this.listKey(botId));
|
||||
}
|
||||
|
||||
async clearQueue(botId: string) {
|
||||
await this.redis.client.del(this.listKey(botId));
|
||||
}
|
||||
|
||||
async acknowledgeBeforeOffset(botId: string, offset: number) {
|
||||
if (!offset || offset <= 0) return;
|
||||
const key = this.listKey(botId);
|
||||
const items = await this.redis.client.lrange(key, 0, -1);
|
||||
if (!items.length) return;
|
||||
|
||||
const remaining = items.filter((item) => {
|
||||
try {
|
||||
const parsed = JSON.parse(item) as StoredTelegramUpdate;
|
||||
return parsed.update_id >= offset;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
const pipeline = this.redis.client.pipeline();
|
||||
pipeline.del(key);
|
||||
if (remaining.length) {
|
||||
pipeline.rpush(key, ...remaining);
|
||||
}
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async fetchUpdates(botId: string, offset: number, limit: number) {
|
||||
const safeLimit = Math.min(Math.max(limit, 1), 100);
|
||||
const items = await this.redis.client.lrange(this.listKey(botId), 0, -1);
|
||||
const parsed = items
|
||||
.map((item) => {
|
||||
try {
|
||||
return JSON.parse(item) as StoredTelegramUpdate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((item): item is StoredTelegramUpdate => Boolean(item))
|
||||
.filter((item) => item.update_id >= offset)
|
||||
.sort((left, right) => left.update_id - right.update_id)
|
||||
.slice(0, safeLimit);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async getUpdates(botId: string, offset: number, limit: number, timeoutSeconds: number) {
|
||||
const safeTimeout = Math.min(Math.max(timeoutSeconds, 0), 50);
|
||||
await this.acknowledgeBeforeOffset(botId, offset);
|
||||
|
||||
const immediate = await this.fetchUpdates(botId, offset, limit);
|
||||
if (immediate.length || safeTimeout <= 0) {
|
||||
return immediate;
|
||||
}
|
||||
|
||||
return new Promise<StoredTelegramUpdate[]>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = async () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
this.emitter.off(this.waitKey(botId), onWake);
|
||||
resolve(await this.fetchUpdates(botId, offset, limit));
|
||||
};
|
||||
|
||||
const onWake = () => {
|
||||
void finish();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
void finish();
|
||||
}, safeTimeout * 1000);
|
||||
|
||||
this.emitter.once(this.waitKey(botId), onWake);
|
||||
|
||||
if (!this.subscriberReady) {
|
||||
void this.pollUntilUpdates(botId, offset, safeTimeout * 1000).then(() => finish());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async pollUntilUpdates(botId: string, offset: number, maxWaitMs: number) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < maxWaitMs) {
|
||||
const updates = await this.fetchUpdates(botId, offset, 1);
|
||||
if (updates.length) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import type { TelegramUpdate } from './telegram-serializer.service';
|
||||
|
||||
@Injectable()
|
||||
export class BotWebhookDispatcherService {
|
||||
private readonly logger = new Logger(BotWebhookDispatcherService.name);
|
||||
|
||||
constructor(private readonly settings: SettingsService) {}
|
||||
|
||||
async dispatch(webhookUrl: string, update: TelegramUpdate, secretToken?: string | null) {
|
||||
const timeoutMs = await this.settings.getNumber('BOT_WEBHOOK_TIMEOUT_MS', 10_000);
|
||||
const maxAttempts = await this.settings.getNumber('BOT_WEBHOOK_MAX_RETRIES', 3);
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(secretToken ? { 'X-Telegram-Bot-Api-Secret-Token': secretToken } : {})
|
||||
},
|
||||
body: JSON.stringify(update),
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastError = new Error(`Webhook HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
const delayMs = Math.min(1000 * 2 ** (attempt - 1), 8000);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`Не удалось доставить update ${update.update_id} на webhook ${webhookUrl}: ${
|
||||
lastError instanceof Error ? lastError.message : lastError
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
19
apps/sso-core/src/domain/bot/telegram-response.util.ts
Normal file
19
apps/sso-core/src/domain/bot/telegram-response.util.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type TelegramApiResponse<T = unknown> =
|
||||
| { ok: true; result: T }
|
||||
| { ok: false; error_code: number; description: string; parameters?: { retry_after?: number } };
|
||||
|
||||
export function telegramOk<T>(result: T): TelegramApiResponse<T> {
|
||||
return { ok: true, result };
|
||||
}
|
||||
|
||||
export function telegramError(
|
||||
errorCode: number,
|
||||
description: string,
|
||||
parameters?: { retry_after?: number }
|
||||
): TelegramApiResponse<never> {
|
||||
return parameters ? { ok: false, error_code: errorCode, description, parameters } : { ok: false, error_code: errorCode, description };
|
||||
}
|
||||
|
||||
export function serializeTelegramResponse(response: TelegramApiResponse) {
|
||||
return JSON.stringify(response);
|
||||
}
|
||||
470
apps/sso-core/src/domain/bot/telegram-serializer.service.ts
Normal file
470
apps/sso-core/src/domain/bot/telegram-serializer.service.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { deriveTelegramChatId } from './bot-token.util';
|
||||
|
||||
export type InternalInlineButton = {
|
||||
text: string;
|
||||
callbackData?: string;
|
||||
url?: string;
|
||||
webAppUrl?: string;
|
||||
};
|
||||
|
||||
export type InternalReplyButton = {
|
||||
text: string;
|
||||
requestContact?: boolean;
|
||||
requestLocation?: boolean;
|
||||
};
|
||||
|
||||
export type InternalReplyMarkup =
|
||||
| {
|
||||
kind: 'inline';
|
||||
rows: InternalInlineButton[][];
|
||||
}
|
||||
| {
|
||||
kind: 'reply';
|
||||
rows: InternalReplyButton[][];
|
||||
resizeKeyboard?: boolean;
|
||||
oneTimeKeyboard?: boolean;
|
||||
selective?: boolean;
|
||||
inputFieldPlaceholder?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'remove';
|
||||
selective?: boolean;
|
||||
};
|
||||
|
||||
export type ParsedReplyMarkup = {
|
||||
internal: InternalReplyMarkup | null;
|
||||
telegram: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type StoredReplyMarkupPayload = {
|
||||
replyMarkup: InternalReplyMarkup | null;
|
||||
telegramReplyMarkup: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type TelegramMessageObject = {
|
||||
message_id: number;
|
||||
from: {
|
||||
id: number;
|
||||
is_bot: boolean;
|
||||
first_name: string;
|
||||
username?: string;
|
||||
};
|
||||
chat: {
|
||||
id: number;
|
||||
type: string;
|
||||
first_name?: string;
|
||||
username?: string;
|
||||
};
|
||||
date: number;
|
||||
edit_date?: number;
|
||||
text?: string;
|
||||
caption?: string;
|
||||
photo?: Array<{ file_id: string; width: number; height: number }>;
|
||||
document?: { file_id: string; file_name?: string };
|
||||
reply_markup?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type BotInboundInternalMessage = {
|
||||
updateId: number;
|
||||
botId: string;
|
||||
senderUserId: string;
|
||||
senderDisplayName: string;
|
||||
senderUsername?: string | null;
|
||||
telegramChatId: number;
|
||||
telegramMessageId: number;
|
||||
text?: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type BotCallbackInternalEvent = {
|
||||
updateId: number;
|
||||
botId: string;
|
||||
callbackQueryId: string;
|
||||
senderUserId: string;
|
||||
senderDisplayName: string;
|
||||
senderUsername?: string | null;
|
||||
telegramChatId: number;
|
||||
callbackData: string;
|
||||
sourceMessage: TelegramMessageObject;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type TelegramUpdate =
|
||||
| {
|
||||
update_id: number;
|
||||
message: TelegramMessageObject;
|
||||
}
|
||||
| {
|
||||
update_id: number;
|
||||
callback_query: {
|
||||
id: string;
|
||||
from: {
|
||||
id: number;
|
||||
is_bot: boolean;
|
||||
first_name: string;
|
||||
username?: string;
|
||||
};
|
||||
message: TelegramMessageObject;
|
||||
chat_instance: string;
|
||||
data?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type StoredBotMessage = {
|
||||
telegramMsgId: number;
|
||||
text?: string | null;
|
||||
messageType?: string | null;
|
||||
mediaUrl?: string | null;
|
||||
createdAt: Date;
|
||||
editedAt?: Date | null;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TelegramSerializerService {
|
||||
private readonly logger = new Logger(TelegramSerializerService.name);
|
||||
|
||||
parseReplyMarkup(replyMarkup: unknown): ParsedReplyMarkup {
|
||||
const markup = this.coerceReplyMarkupInput(replyMarkup);
|
||||
if (!markup) {
|
||||
return { internal: null, telegram: null };
|
||||
}
|
||||
|
||||
this.logger.debug(`Parsed Reply Markup: ${JSON.stringify(markup)}`);
|
||||
|
||||
if (markup.remove_keyboard === true) {
|
||||
const internal: InternalReplyMarkup = {
|
||||
kind: 'remove',
|
||||
selective: markup.selective === true
|
||||
};
|
||||
return { internal, telegram: { remove_keyboard: true, ...(markup.selective ? { selective: true } : {}) } };
|
||||
}
|
||||
|
||||
if (Array.isArray(markup.inline_keyboard)) {
|
||||
const rows = (markup.inline_keyboard as unknown[]).map((row) =>
|
||||
Array.isArray(row)
|
||||
? row
|
||||
.map((button) => this.parseInlineButton(button))
|
||||
.filter((button): button is InternalInlineButton => Boolean(button))
|
||||
: []
|
||||
);
|
||||
|
||||
const telegram = { inline_keyboard: this.toTelegramInlineKeyboard(rows) };
|
||||
return { internal: { kind: 'inline', rows }, telegram };
|
||||
}
|
||||
|
||||
if (Array.isArray(markup.keyboard)) {
|
||||
const rows = (markup.keyboard as unknown[]).map((row) =>
|
||||
Array.isArray(row)
|
||||
? row
|
||||
.map((button) => this.parseReplyButton(button))
|
||||
.filter((button): button is InternalReplyButton => Boolean(button))
|
||||
: []
|
||||
);
|
||||
|
||||
const internal: InternalReplyMarkup = {
|
||||
kind: 'reply',
|
||||
rows,
|
||||
resizeKeyboard: markup.resize_keyboard === true,
|
||||
oneTimeKeyboard: markup.one_time_keyboard === true,
|
||||
selective: markup.selective === true,
|
||||
inputFieldPlaceholder:
|
||||
typeof markup.input_field_placeholder === 'string' ? markup.input_field_placeholder : undefined
|
||||
};
|
||||
|
||||
const telegram: Record<string, unknown> = {
|
||||
keyboard: rows.map((row) =>
|
||||
row.map((button) => ({
|
||||
text: button.text,
|
||||
...(button.requestContact ? { request_contact: true } : {}),
|
||||
...(button.requestLocation ? { request_location: true } : {})
|
||||
}))
|
||||
)
|
||||
};
|
||||
if (internal.kind === 'reply') {
|
||||
if (internal.resizeKeyboard) telegram.resize_keyboard = true;
|
||||
if (internal.oneTimeKeyboard) telegram.one_time_keyboard = true;
|
||||
if (internal.selective) telegram.selective = true;
|
||||
if (internal.inputFieldPlaceholder) telegram.input_field_placeholder = internal.inputFieldPlaceholder;
|
||||
}
|
||||
|
||||
return { internal, telegram };
|
||||
}
|
||||
|
||||
return { internal: null, telegram: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* node-telegram-bot-api и другие клиенты часто передают reply_markup как JSON-строку
|
||||
* (особенно при application/x-www-form-urlencoded). Приводим к объекту Telegram API.
|
||||
*/
|
||||
private coerceReplyMarkupInput(replyMarkup: unknown): Record<string, unknown> | null {
|
||||
let current: unknown = replyMarkup;
|
||||
|
||||
for (let depth = 0; depth < 3; depth += 1) {
|
||||
if (current === null || current === undefined || current === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof current === 'string') {
|
||||
const trimmed = current.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
current = JSON.parse(trimmed) as unknown;
|
||||
continue;
|
||||
} catch {
|
||||
this.logger.warn('Не удалось распарсить reply_markup как JSON-строку');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof current === 'object' && !Array.isArray(current)) {
|
||||
return this.normalizeReplyMarkupObject(current as Record<string, unknown>);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.warn('reply_markup содержит слишком глубокую JSON-вложенность');
|
||||
return null;
|
||||
}
|
||||
|
||||
private normalizeReplyMarkupObject(markup: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized: Record<string, unknown> = { ...markup };
|
||||
|
||||
if (typeof normalized.inline_keyboard === 'string') {
|
||||
try {
|
||||
normalized.inline_keyboard = JSON.parse(normalized.inline_keyboard.trim()) as unknown;
|
||||
} catch {
|
||||
this.logger.warn('Не удалось распарсить inline_keyboard внутри reply_markup');
|
||||
delete normalized.inline_keyboard;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof normalized.keyboard === 'string') {
|
||||
try {
|
||||
normalized.keyboard = JSON.parse(normalized.keyboard.trim()) as unknown;
|
||||
} catch {
|
||||
this.logger.warn('Не удалось распарсить keyboard внутри reply_markup');
|
||||
delete normalized.keyboard;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
toTelegramUpdate(internalMessage: BotInboundInternalMessage): TelegramUpdate {
|
||||
return {
|
||||
update_id: internalMessage.updateId,
|
||||
message: this.buildTextMessage({
|
||||
messageId: internalMessage.telegramMessageId,
|
||||
chatId: internalMessage.telegramChatId,
|
||||
chatType: 'private',
|
||||
from: this.buildUserFrom(internalMessage),
|
||||
date: internalMessage.createdAt,
|
||||
text: internalMessage.text ?? undefined
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
toTelegramCallbackUpdate(event: BotCallbackInternalEvent): TelegramUpdate {
|
||||
const firstName = event.senderDisplayName.trim() || 'User';
|
||||
const username = event.senderUsername?.trim() || undefined;
|
||||
return {
|
||||
update_id: event.updateId,
|
||||
callback_query: {
|
||||
id: event.callbackQueryId,
|
||||
from: {
|
||||
id: event.telegramChatId,
|
||||
is_bot: false,
|
||||
first_name: firstName,
|
||||
...(username ? { username } : {})
|
||||
},
|
||||
message: event.sourceMessage,
|
||||
chat_instance: `${event.botId}:${event.senderUserId}`,
|
||||
data: event.callbackData
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
buildBotMessageObject(input: {
|
||||
message: StoredBotMessage;
|
||||
bot: { id: string; name: string; username: string };
|
||||
chat: { telegramChatId: bigint | number; chatType: string; recipientDisplayName: string; recipientUsername?: string | null };
|
||||
}): TelegramMessageObject {
|
||||
const chatId = Number(input.chat.telegramChatId);
|
||||
const payload = this.readPayload(input.message.payload);
|
||||
const replyMarkup = payload.telegramReplyMarkup ?? undefined;
|
||||
|
||||
const base = {
|
||||
message_id: input.message.telegramMsgId,
|
||||
from: {
|
||||
id: Number(deriveTelegramChatId(input.bot.id)),
|
||||
is_bot: true,
|
||||
first_name: input.bot.name,
|
||||
username: input.bot.username
|
||||
},
|
||||
chat: {
|
||||
id: chatId,
|
||||
type: input.chat.chatType,
|
||||
first_name: input.chat.recipientDisplayName,
|
||||
...(input.chat.recipientUsername ? { username: input.chat.recipientUsername } : {})
|
||||
},
|
||||
date: Math.floor(input.message.createdAt.getTime() / 1000),
|
||||
...(input.message.editedAt ? { edit_date: Math.floor(input.message.editedAt.getTime() / 1000) } : {}),
|
||||
...(replyMarkup ? { reply_markup: replyMarkup } : {})
|
||||
};
|
||||
|
||||
const messageType = input.message.messageType ?? 'text';
|
||||
if (messageType === 'photo' && input.message.mediaUrl) {
|
||||
return {
|
||||
...base,
|
||||
photo: [{ file_id: input.message.mediaUrl, width: 320, height: 240 }],
|
||||
...(input.message.text ? { caption: input.message.text } : {})
|
||||
};
|
||||
}
|
||||
|
||||
if (messageType === 'document' && input.message.mediaUrl) {
|
||||
return {
|
||||
...base,
|
||||
document: {
|
||||
file_id: input.message.mediaUrl,
|
||||
file_name: this.extractFileName(input.message.mediaUrl)
|
||||
},
|
||||
...(input.message.text ? { caption: input.message.text } : {})
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(input.message.text ? { text: input.message.text } : {})
|
||||
};
|
||||
}
|
||||
|
||||
buildTextMessage(input: {
|
||||
messageId: number;
|
||||
chatId: number;
|
||||
chatType: string;
|
||||
from: { id: number; displayName: string; username?: string | null; isBot?: boolean };
|
||||
date: Date;
|
||||
text?: string;
|
||||
editDate?: Date | null;
|
||||
replyMarkup?: Record<string, unknown> | null;
|
||||
}): TelegramMessageObject {
|
||||
return {
|
||||
message_id: input.messageId,
|
||||
from: {
|
||||
id: input.from.id,
|
||||
is_bot: input.from.isBot ?? false,
|
||||
first_name: input.from.displayName,
|
||||
...(input.from.username ? { username: input.from.username } : {})
|
||||
},
|
||||
chat: {
|
||||
id: input.chatId,
|
||||
type: input.chatType,
|
||||
first_name: input.from.displayName,
|
||||
...(input.from.username ? { username: input.from.username } : {})
|
||||
},
|
||||
date: Math.floor(input.date.getTime() / 1000),
|
||||
...(input.editDate ? { edit_date: Math.floor(input.editDate.getTime() / 1000) } : {}),
|
||||
...(input.text ? { text: input.text } : {}),
|
||||
...(input.replyMarkup ? { reply_markup: input.replyMarkup } : {})
|
||||
};
|
||||
}
|
||||
|
||||
readPayload(payload: unknown): StoredReplyMarkupPayload {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return { replyMarkup: null, telegramReplyMarkup: null };
|
||||
}
|
||||
const record = payload as {
|
||||
replyMarkup?: InternalReplyMarkup | null;
|
||||
telegramReplyMarkup?: Record<string, unknown> | null;
|
||||
reply_markup?: unknown;
|
||||
};
|
||||
if (!record.telegramReplyMarkup && record.reply_markup) {
|
||||
const parsed = this.parseReplyMarkup(record.reply_markup);
|
||||
return { replyMarkup: parsed.internal, telegramReplyMarkup: parsed.telegram };
|
||||
}
|
||||
return {
|
||||
replyMarkup: record.replyMarkup ?? null,
|
||||
telegramReplyMarkup: record.telegramReplyMarkup ?? null
|
||||
};
|
||||
}
|
||||
|
||||
readPayloadAsParsed(payload: unknown): ParsedReplyMarkup {
|
||||
const stored = this.readPayload(payload);
|
||||
return { internal: stored.replyMarkup, telegram: stored.telegramReplyMarkup };
|
||||
}
|
||||
|
||||
buildStoredPayload(parsed: ParsedReplyMarkup) {
|
||||
if (!parsed.internal && !parsed.telegram) return undefined;
|
||||
return {
|
||||
replyMarkup: parsed.internal,
|
||||
telegramReplyMarkup: parsed.telegram
|
||||
};
|
||||
}
|
||||
|
||||
private buildUserFrom(input: {
|
||||
telegramChatId: number;
|
||||
senderDisplayName: string;
|
||||
senderUsername?: string | null;
|
||||
}) {
|
||||
const firstName = input.senderDisplayName.trim() || 'User';
|
||||
const username = input.senderUsername?.trim() || undefined;
|
||||
return {
|
||||
id: input.telegramChatId,
|
||||
displayName: firstName,
|
||||
username,
|
||||
isBot: false
|
||||
};
|
||||
}
|
||||
|
||||
private parseInlineButton(raw: unknown): InternalInlineButton | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const button = raw as Record<string, unknown>;
|
||||
const text = typeof button.text === 'string' ? button.text.trim() : '';
|
||||
if (!text) return null;
|
||||
|
||||
const webApp = button.web_app as { url?: string } | undefined;
|
||||
return {
|
||||
text,
|
||||
callbackData: typeof button.callback_data === 'string' ? button.callback_data : undefined,
|
||||
url: typeof button.url === 'string' ? button.url : undefined,
|
||||
webAppUrl: typeof webApp?.url === 'string' ? webApp.url : undefined
|
||||
};
|
||||
}
|
||||
|
||||
private parseReplyButton(raw: unknown): InternalReplyButton | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const button = raw as Record<string, unknown>;
|
||||
const text = typeof button.text === 'string' ? button.text.trim() : '';
|
||||
if (!text) return null;
|
||||
return {
|
||||
text,
|
||||
requestContact: button.request_contact === true,
|
||||
requestLocation: button.request_location === true
|
||||
};
|
||||
}
|
||||
|
||||
private toTelegramInlineKeyboard(rows: InternalInlineButton[][]) {
|
||||
return rows.map((row) =>
|
||||
row.map((button) => ({
|
||||
text: button.text,
|
||||
...(button.callbackData ? { callback_data: button.callbackData } : {}),
|
||||
...(button.url ? { url: button.url } : {}),
|
||||
...(button.webAppUrl ? { web_app: { url: button.webAppUrl } } : {})
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
private extractFileName(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const segments = parsed.pathname.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1] || 'document';
|
||||
} catch {
|
||||
return 'document';
|
||||
}
|
||||
}
|
||||
}
|
||||
54
apps/sso-core/src/domain/bot/webapp-crypto.service.ts
Normal file
54
apps/sso-core/src/domain/bot/webapp-crypto.service.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
@Injectable()
|
||||
export class WebAppCryptoService {
|
||||
validateWebAppData(initData: string, botToken: string, maxAgeSeconds = 86_400) {
|
||||
if (!initData?.trim() || !botToken?.trim()) {
|
||||
return { valid: false as const, error: 'Пустые initData или botToken' };
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(initData);
|
||||
const receivedHash = params.get('hash');
|
||||
if (!receivedHash) {
|
||||
return { valid: false as const, error: 'Отсутствует hash в initData' };
|
||||
}
|
||||
|
||||
const authDateRaw = params.get('auth_date');
|
||||
if (!authDateRaw) {
|
||||
return { valid: false as const, error: 'Отсутствует auth_date в initData' };
|
||||
}
|
||||
|
||||
const authDate = Number(authDateRaw);
|
||||
if (!Number.isFinite(authDate)) {
|
||||
return { valid: false as const, error: 'Некорректный auth_date' };
|
||||
}
|
||||
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
if (nowSeconds - authDate > maxAgeSeconds) {
|
||||
return { valid: false as const, error: 'Срок действия initData истёк' };
|
||||
}
|
||||
|
||||
const dataCheckString = [...params.entries()]
|
||||
.filter(([key]) => key !== 'hash')
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n');
|
||||
|
||||
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest();
|
||||
const calculatedHash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex');
|
||||
|
||||
const receivedBuffer = Buffer.from(receivedHash, 'hex');
|
||||
const calculatedBuffer = Buffer.from(calculatedHash, 'hex');
|
||||
if (receivedBuffer.length !== calculatedBuffer.length || !timingSafeEqual(receivedBuffer, calculatedBuffer)) {
|
||||
return { valid: false as const, error: 'Подпись initData не совпадает' };
|
||||
}
|
||||
|
||||
const userRaw = params.get('user');
|
||||
return {
|
||||
valid: true as const,
|
||||
userJson: userRaw ?? undefined,
|
||||
authDate: authDateRaw
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { MinioService } from '../infra/minio.service';
|
||||
import { resolveVerificationIcon } from './verification.constants';
|
||||
import { FamilyService } from './family.service';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
@@ -21,11 +22,19 @@ export class ChatService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly family: FamilyService,
|
||||
private readonly notifications: NotificationsService
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly minio: MinioService
|
||||
) {}
|
||||
|
||||
async listRooms(userId: string, groupId: string) {
|
||||
await this.family.assertFamilyMember(groupId, userId);
|
||||
await this.syncFamilyChats(groupId);
|
||||
|
||||
await this.prisma.chatRoom.updateMany({
|
||||
where: { groupId, type: 'DIRECT', isE2E: true },
|
||||
data: { isE2E: false }
|
||||
});
|
||||
|
||||
let generalRoom = await this.prisma.chatRoom.findFirst({ where: { groupId, type: 'GENERAL' } });
|
||||
if (!generalRoom) {
|
||||
const members = await this.prisma.familyMember.findMany({ where: { groupId } });
|
||||
@@ -34,10 +43,11 @@ export class ChatService {
|
||||
groupId,
|
||||
type: 'GENERAL',
|
||||
name: 'Общий чат',
|
||||
members: { create: members.map((member) => ({ userId: member.userId })) }
|
||||
members: { create: members.filter((member) => member.role !== 'bot').map((member) => ({ userId: member.userId })) }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const rooms = await this.prisma.chatRoom.findMany({
|
||||
where: {
|
||||
groupId,
|
||||
@@ -57,21 +67,122 @@ export class ChatService {
|
||||
orderBy: [{ type: 'asc' }, { updatedAt: 'desc' }]
|
||||
});
|
||||
|
||||
const dedupedRooms = rooms.filter((room) => {
|
||||
if (room.type !== 'GROUP' || room.members.length !== 2) return true;
|
||||
const memberIds = room.members.map((member) => member.userId).sort().join(':');
|
||||
return !rooms.some(
|
||||
(candidate) =>
|
||||
candidate.id !== room.id &&
|
||||
(candidate.type === 'DIRECT' || candidate.type === 'BOT') &&
|
||||
candidate.members.map((member) => member.userId).sort().join(':') === memberIds
|
||||
);
|
||||
});
|
||||
|
||||
const familyMembers = await this.prisma.familyMember.findMany({ where: { groupId } });
|
||||
const familyRoleByUserId = new Map(familyMembers.map((member) => [member.userId, member.role]));
|
||||
|
||||
return {
|
||||
rooms: await Promise.all(
|
||||
rooms.map(async (room) => {
|
||||
const lastMessage = room.messages[0];
|
||||
return this.formatRoom(
|
||||
room,
|
||||
familyRoleByUserId,
|
||||
lastMessage ? await this.toMessage(lastMessage, userId, await this.getRoomReadContext(room.id)) : undefined
|
||||
);
|
||||
})
|
||||
)
|
||||
};
|
||||
const formatted = await Promise.all(
|
||||
dedupedRooms.map(async (room) => {
|
||||
let lastMessage = room.messages[0]
|
||||
? await this.toMessage(room.messages[0], userId, await this.getRoomReadContext(room.id))
|
||||
: undefined;
|
||||
|
||||
if (room.type === 'BOT' && room.botUsername) {
|
||||
const botPreview = await this.getBotRoomPreview(userId, room.botUsername);
|
||||
if (botPreview) {
|
||||
lastMessage = botPreview;
|
||||
}
|
||||
}
|
||||
|
||||
return this.formatRoom(
|
||||
room,
|
||||
familyRoleByUserId,
|
||||
lastMessage,
|
||||
userId
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
formatted.sort((a, b) => {
|
||||
const rank = (type: string) => (type === 'GENERAL' ? 0 : 1);
|
||||
const rankDiff = rank(a.type) - rank(b.type);
|
||||
if (rankDiff !== 0) return rankDiff;
|
||||
const aTime = a.lastMessage?.createdAt ?? a.updatedAt;
|
||||
const bTime = b.lastMessage?.createdAt ?? b.updatedAt;
|
||||
return bTime.localeCompare(aTime);
|
||||
});
|
||||
|
||||
return { rooms: formatted };
|
||||
}
|
||||
|
||||
async syncFamilyChats(groupId: string) {
|
||||
const members = await this.prisma.familyMember.findMany({
|
||||
where: { groupId },
|
||||
include: { user: { include: { linkedBot: { select: { username: true } } } } }
|
||||
});
|
||||
|
||||
const humans = members.filter((member) => !member.user.linkedBotId);
|
||||
const bots = members.filter((member) => member.user.linkedBotId);
|
||||
|
||||
const legacyRooms = await this.prisma.chatRoom.findMany({
|
||||
where: { groupId, type: 'GROUP' },
|
||||
include: { members: true }
|
||||
});
|
||||
|
||||
for (const room of legacyRooms) {
|
||||
if (room.members.length !== 2) continue;
|
||||
const memberIds = room.members.map((member) => member.userId);
|
||||
const memberA = members.find((member) => member.userId === memberIds[0]);
|
||||
const memberB = members.find((member) => member.userId === memberIds[1]);
|
||||
const aIsBot = Boolean(memberA?.user.linkedBotId);
|
||||
const bIsBot = Boolean(memberB?.user.linkedBotId);
|
||||
|
||||
if (aIsBot || bIsBot) {
|
||||
const humanId = aIsBot ? memberIds[1]! : memberIds[0]!;
|
||||
const botId = aIsBot ? memberIds[0]! : memberIds[1]!;
|
||||
const botMember = members.find((member) => member.userId === botId);
|
||||
await this.prisma.chatRoom.update({
|
||||
where: { id: room.id },
|
||||
data: {
|
||||
type: 'BOT',
|
||||
peerUserId: botId,
|
||||
botUsername: botMember?.user.linkedBot?.username ?? undefined,
|
||||
name: botMember?.user.displayName ?? room.name,
|
||||
isE2E: false
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await this.prisma.chatRoom.update({
|
||||
where: { id: room.id },
|
||||
data: {
|
||||
type: 'DIRECT',
|
||||
isE2E: false,
|
||||
peerUserId: memberIds[1],
|
||||
name: memberB?.user.displayName ?? room.name
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < humans.length; i += 1) {
|
||||
for (let j = i + 1; j < humans.length; j += 1) {
|
||||
const left = humans[i]!;
|
||||
const right = humans[j]!;
|
||||
await this.ensureDirectRoom(groupId, left.userId, right.userId, right.user.displayName);
|
||||
}
|
||||
}
|
||||
|
||||
for (const botMember of bots) {
|
||||
for (const human of humans) {
|
||||
await this.ensureBotRoom(
|
||||
groupId,
|
||||
human.userId,
|
||||
botMember.userId,
|
||||
botMember.user.linkedBot!.username,
|
||||
botMember.user.displayName
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createRoom(userId: string, groupId: string, name: string, memberUserIds: string[]) {
|
||||
@@ -82,6 +193,9 @@ export class ChatService {
|
||||
}
|
||||
|
||||
const uniqueMembers = Array.from(new Set([userId, ...memberUserIds]));
|
||||
if (uniqueMembers.length === 2) {
|
||||
throw new BadRequestException('Личный чат создаётся автоматически при добавлении участника в семью');
|
||||
}
|
||||
for (const memberId of uniqueMembers) {
|
||||
if (!group.members.some((member) => member.userId === memberId)) {
|
||||
throw new BadRequestException('Все участники чата должны состоять в семье');
|
||||
@@ -107,7 +221,86 @@ export class ChatService {
|
||||
const familyMembers = await this.prisma.familyMember.findMany({ where: { groupId } });
|
||||
const familyRoleByUserId = new Map(familyMembers.map((member) => [member.userId, member.role]));
|
||||
|
||||
return this.formatRoom(room, familyRoleByUserId);
|
||||
return this.formatRoom(room, familyRoleByUserId, undefined, userId);
|
||||
}
|
||||
|
||||
async createE2ERoom(userId: string, groupId: string, peerUserId: string) {
|
||||
if (userId === peerUserId) {
|
||||
throw new BadRequestException('Нельзя создать секретный чат с самим собой');
|
||||
}
|
||||
|
||||
const group = await this.family.assertFamilyMember(groupId, userId);
|
||||
if (!group.members.some((member) => member.userId === peerUserId)) {
|
||||
throw new BadRequestException('Пользователь не состоит в семье');
|
||||
}
|
||||
|
||||
const peerUser = await this.prisma.user.findUnique({
|
||||
where: { id: peerUserId },
|
||||
select: { displayName: true, linkedBotId: true, isSystemAccount: true }
|
||||
});
|
||||
if (!peerUser || peerUser.linkedBotId || peerUser.isSystemAccount) {
|
||||
throw new BadRequestException('Секретный E2E чат доступен только с участниками семьи');
|
||||
}
|
||||
|
||||
const existing = await this.findPairRoom(groupId, userId, peerUserId, 'E2E');
|
||||
if (existing) {
|
||||
return this.getRoomResponse(existing.id, userId);
|
||||
}
|
||||
|
||||
const room = await this.prisma.chatRoom.create({
|
||||
data: {
|
||||
groupId,
|
||||
type: 'E2E',
|
||||
name: peerUser.displayName,
|
||||
peerUserId,
|
||||
isE2E: true,
|
||||
members: {
|
||||
create: [{ userId }, { userId: peerUserId }]
|
||||
}
|
||||
},
|
||||
include: {
|
||||
members: { include: { user: true } },
|
||||
messages: true
|
||||
}
|
||||
});
|
||||
|
||||
const familyMembers = await this.prisma.familyMember.findMany({ where: { groupId } });
|
||||
const familyRoleByUserId = new Map(familyMembers.map((member) => [member.userId, member.role]));
|
||||
return this.formatRoom(room, familyRoleByUserId, undefined, userId);
|
||||
}
|
||||
|
||||
async deleteRoom(userId: string, roomId: string) {
|
||||
const room = await this.getRoomForMember(roomId, userId);
|
||||
if (room.type === 'GENERAL') {
|
||||
throw new BadRequestException('Нельзя удалить общий чат семьи');
|
||||
}
|
||||
|
||||
if (room.type === 'GROUP') {
|
||||
const isFamilyOwner = room.group.ownerId === userId;
|
||||
const isChatCreator = room.createdById === userId;
|
||||
if (!isFamilyOwner && !isChatCreator) {
|
||||
throw new ForbiddenException('Удалить групповой чат может только создатель чата или семьи');
|
||||
}
|
||||
}
|
||||
|
||||
const messages = await this.prisma.chatMessage.findMany({
|
||||
where: { roomId },
|
||||
select: { storageKey: true }
|
||||
});
|
||||
const storageKeys = messages
|
||||
.map((message) => message.storageKey)
|
||||
.filter((key): key is string => Boolean(key));
|
||||
if (room.avatarStorageKey) {
|
||||
storageKeys.push(room.avatarStorageKey);
|
||||
}
|
||||
|
||||
await this.prisma.chatRoom.delete({ where: { id: roomId } });
|
||||
|
||||
if (storageKeys.length) {
|
||||
await this.minio.deleteObjects(storageKeys).catch(() => undefined);
|
||||
}
|
||||
|
||||
return { count: 1 };
|
||||
}
|
||||
|
||||
async addRoomMember(userId: string, roomId: string, memberUserId: string) {
|
||||
@@ -115,6 +308,9 @@ export class ChatService {
|
||||
if (room.type === 'GENERAL') {
|
||||
throw new BadRequestException('В общий чат нельзя добавлять участников вручную');
|
||||
}
|
||||
if (room.type === 'DIRECT' || room.type === 'BOT' || room.type === 'E2E') {
|
||||
throw new BadRequestException('Нельзя изменять состав личного чата или чата с ботом');
|
||||
}
|
||||
if (room.members.some((member) => member.userId === memberUserId)) {
|
||||
throw new BadRequestException('Пользователь уже состоит в чате');
|
||||
}
|
||||
@@ -134,6 +330,9 @@ export class ChatService {
|
||||
if (room.type === 'GENERAL') {
|
||||
throw new BadRequestException('Нельзя удалить участника из общего чата');
|
||||
}
|
||||
if (room.type === 'DIRECT' || room.type === 'BOT' || room.type === 'E2E') {
|
||||
throw new BadRequestException('Нельзя изменять состав личного чата или чата с ботом');
|
||||
}
|
||||
|
||||
const isFamilyOwner = room.group.ownerId === userId;
|
||||
const isChatCreator = room.createdById === userId;
|
||||
@@ -171,6 +370,9 @@ export class ChatService {
|
||||
if (room.type === 'GENERAL') {
|
||||
throw new BadRequestException('Нельзя переименовать общий чат');
|
||||
}
|
||||
if (room.type === 'DIRECT' || room.type === 'BOT' || room.type === 'E2E') {
|
||||
throw new BadRequestException('Нельзя переименовать личный чат или чат с ботом');
|
||||
}
|
||||
await this.prisma.chatRoom.update({ where: { id: roomId }, data: { name: trimmedName } });
|
||||
}
|
||||
if (notificationsMuted !== undefined) {
|
||||
@@ -183,7 +385,10 @@ export class ChatService {
|
||||
}
|
||||
|
||||
async listMessages(userId: string, roomId: string, beforeMessageId?: string, limit = 50) {
|
||||
await this.getRoomForMember(roomId, userId);
|
||||
const room = await this.getRoomForMember(roomId, userId);
|
||||
if (room.type === 'BOT') {
|
||||
throw new BadRequestException('Сообщения бота загружаются через Bot API');
|
||||
}
|
||||
const take = Math.min(Math.max(limit, 1), 100);
|
||||
const readContext = await this.getRoomReadContext(roomId);
|
||||
|
||||
@@ -221,9 +426,20 @@ export class ChatService {
|
||||
storageKey?: string,
|
||||
mimeType?: string,
|
||||
metadataJson?: string,
|
||||
poll?: PollPayload
|
||||
poll?: PollPayload,
|
||||
isEncrypted?: boolean
|
||||
) {
|
||||
const room = await this.getRoomForMember(roomId, userId);
|
||||
if (room.type === 'BOT') {
|
||||
throw new BadRequestException('Сообщения боту отправляйте через Bot API');
|
||||
}
|
||||
const requiresEncryption = room.type === 'E2E' || (room.type === 'DIRECT' && room.isE2E);
|
||||
if (requiresEncryption && !isEncrypted) {
|
||||
throw new BadRequestException('Сообщения секретного чата должны быть зашифрованы на клиенте');
|
||||
}
|
||||
if (room.type === 'E2E' && type === 'POLL') {
|
||||
throw new BadRequestException('Опросы недоступны в секретном E2E чате');
|
||||
}
|
||||
const allowedTypes = new Set(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'FILE', 'EMOJI', 'POLL']);
|
||||
if (!allowedTypes.has(type)) {
|
||||
throw new BadRequestException('Неподдерживаемый тип сообщения');
|
||||
@@ -259,6 +475,7 @@ export class ChatService {
|
||||
senderId: userId,
|
||||
type,
|
||||
content: content?.trim() || null,
|
||||
isEncrypted: Boolean(isEncrypted),
|
||||
replyToId: replyToId || null,
|
||||
storageKey: storageKey || null,
|
||||
mimeType: mimeType || null,
|
||||
@@ -299,6 +516,42 @@ export class ChatService {
|
||||
return response;
|
||||
}
|
||||
|
||||
async publishRoomAvatarChangedMessage(roomId: string, userId: string) {
|
||||
const room = await this.prisma.chatRoom.findUnique({
|
||||
where: { id: roomId },
|
||||
include: { members: true }
|
||||
});
|
||||
if (!room || (room.type !== 'GROUP' && room.type !== 'GENERAL')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
const userName = user?.displayName?.trim() || 'Участник';
|
||||
const content = `${userName} изменил(а) фото чата`;
|
||||
|
||||
const message = await this.prisma.chatMessage.create({
|
||||
data: {
|
||||
roomId,
|
||||
senderId: userId,
|
||||
type: 'SYSTEM',
|
||||
content,
|
||||
metadata: { isSystem: true, systemKind: 'avatar_changed' }
|
||||
},
|
||||
include: { sender: true }
|
||||
});
|
||||
|
||||
await this.prisma.chatRoom.update({ where: { id: roomId }, data: { updatedAt: new Date() } });
|
||||
|
||||
const readContext = await this.getRoomReadContext(roomId);
|
||||
const response = await this.toMessage(message, userId, readContext);
|
||||
await this.publishRoomEvent(
|
||||
{ id: room.id, name: room.name, members: room.members },
|
||||
'chat_message',
|
||||
response
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
async editMessage(userId: string, messageId: string, content: string) {
|
||||
const message = await this.prisma.chatMessage.findUnique({
|
||||
where: { id: messageId },
|
||||
@@ -310,6 +563,9 @@ export class ChatService {
|
||||
if (message.senderId !== userId) {
|
||||
throw new ForbiddenException('Можно редактировать только свои сообщения');
|
||||
}
|
||||
if (message.isEncrypted) {
|
||||
throw new BadRequestException('Зашифрованные сообщения нельзя редактировать');
|
||||
}
|
||||
if (!['TEXT', 'EMOJI'].includes(message.type)) {
|
||||
throw new BadRequestException('Этот тип сообщения нельзя редактировать');
|
||||
}
|
||||
@@ -351,6 +607,155 @@ export class ChatService {
|
||||
return response;
|
||||
}
|
||||
|
||||
async toggleMessageReaction(userId: string, messageId: string, emoji: string) {
|
||||
const normalizedEmoji = emoji.trim();
|
||||
if (!normalizedEmoji || normalizedEmoji.length > 16) {
|
||||
throw new BadRequestException('Некорректная реакция');
|
||||
}
|
||||
|
||||
const message = await this.prisma.chatMessage.findUnique({
|
||||
where: { id: messageId },
|
||||
include: { room: { include: { members: true } } }
|
||||
});
|
||||
if (!message || message.deletedAt) {
|
||||
throw new NotFoundException('Сообщение не найдено');
|
||||
}
|
||||
if (!message.room.members.some((member) => member.userId === userId)) {
|
||||
throw new ForbiddenException('Вы не состоите в этом чате');
|
||||
}
|
||||
|
||||
const meta = (message.metadata as Record<string, unknown> | null) ?? {};
|
||||
const reactions = { ...((meta.reactions as Record<string, string[]>) ?? {}) };
|
||||
const current = reactions[normalizedEmoji] ?? [];
|
||||
if (current.includes(userId)) {
|
||||
const next = current.filter((id) => id !== userId);
|
||||
if (next.length) {
|
||||
reactions[normalizedEmoji] = next;
|
||||
} else {
|
||||
delete reactions[normalizedEmoji];
|
||||
}
|
||||
} else {
|
||||
reactions[normalizedEmoji] = [...current, userId];
|
||||
}
|
||||
|
||||
await this.prisma.chatMessage.update({
|
||||
where: { id: messageId },
|
||||
data: { metadata: { ...meta, reactions } }
|
||||
});
|
||||
|
||||
const response = await this.loadMessageResponse(messageId, userId);
|
||||
await this.publishRoomEvent(message.room, 'chat_message_updated', response);
|
||||
return response;
|
||||
}
|
||||
|
||||
async forwardMessages(userId: string, targetRoomId: string, messageIds: string[]) {
|
||||
if (!messageIds.length) {
|
||||
throw new BadRequestException('Укажите сообщения для пересылки');
|
||||
}
|
||||
const targetRoom = await this.getRoomForMember(targetRoomId, userId);
|
||||
if (targetRoom.type === 'BOT') {
|
||||
throw new BadRequestException('Нельзя пересылать сообщения в чат с ботом');
|
||||
}
|
||||
|
||||
const uniqueIds = [...new Set(messageIds)];
|
||||
const sourceMessages = await this.prisma.chatMessage.findMany({
|
||||
where: { id: { in: uniqueIds }, deletedAt: null },
|
||||
include: {
|
||||
sender: true,
|
||||
room: { include: { members: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
|
||||
if (sourceMessages.length !== uniqueIds.length) {
|
||||
throw new NotFoundException('Одно или несколько сообщений не найдены');
|
||||
}
|
||||
|
||||
for (const message of sourceMessages) {
|
||||
if (!message.room.members.some((member) => member.userId === userId)) {
|
||||
throw new ForbiddenException('Нет доступа к одному из сообщений');
|
||||
}
|
||||
if (message.isEncrypted) {
|
||||
throw new BadRequestException('Зашифрованные сообщения нельзя пересылать');
|
||||
}
|
||||
}
|
||||
|
||||
const forwarded: Awaited<ReturnType<ChatService['loadMessageResponse']>>[] = [];
|
||||
for (const source of sourceMessages) {
|
||||
let forwardType = source.type;
|
||||
let forwardContent = source.content ?? undefined;
|
||||
let forwardStorageKey = source.storageKey ?? undefined;
|
||||
let forwardMimeType = source.mimeType ?? undefined;
|
||||
|
||||
if (!['TEXT', 'EMOJI'].includes(source.type)) {
|
||||
forwardType = 'TEXT';
|
||||
forwardContent = this.buildForwardPreview(source.type, source.content);
|
||||
forwardStorageKey = undefined;
|
||||
forwardMimeType = undefined;
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
forwardedFrom: {
|
||||
messageId: source.id,
|
||||
roomId: source.roomId,
|
||||
senderId: source.senderId,
|
||||
senderName: source.sender.displayName,
|
||||
type: source.type,
|
||||
content: source.content ?? undefined
|
||||
}
|
||||
};
|
||||
const created = await this.sendMessage(
|
||||
userId,
|
||||
targetRoomId,
|
||||
forwardType,
|
||||
forwardContent,
|
||||
undefined,
|
||||
forwardStorageKey,
|
||||
forwardMimeType,
|
||||
JSON.stringify(metadata),
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
forwarded.push(created);
|
||||
}
|
||||
|
||||
return { messages: forwarded };
|
||||
}
|
||||
|
||||
async reportTyping(userId: string, roomId: string) {
|
||||
const room = await this.getRoomForMember(roomId, userId);
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
const userName = user?.displayName?.trim() || user?.phone || 'Участник';
|
||||
|
||||
for (const member of room.members) {
|
||||
if (member.userId === userId) continue;
|
||||
await this.notifications.publishRealtime(member.userId, 'chat_typing', room.name, '', {
|
||||
roomId: room.id,
|
||||
userId,
|
||||
userName
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private buildForwardPreview(type: string, content?: string | null) {
|
||||
switch (type) {
|
||||
case 'IMAGE':
|
||||
return '📷 Пересланное фото';
|
||||
case 'VOICE':
|
||||
return '🎤 Пересланное голосовое сообщение';
|
||||
case 'AUDIO':
|
||||
return '🎵 Пересланное аудио';
|
||||
case 'FILE':
|
||||
return content ? `📄 Пересланный файл: ${content}` : '📄 Пересланный файл';
|
||||
case 'POLL':
|
||||
return '📊 Пересланный опрос';
|
||||
default:
|
||||
return '↪️ Пересланное сообщение';
|
||||
}
|
||||
}
|
||||
|
||||
async markRoomRead(userId: string, roomId: string, lastMessageId?: string) {
|
||||
const room = await this.getRoomForMember(roomId, userId);
|
||||
let readAt = new Date();
|
||||
@@ -461,6 +866,9 @@ export class ChatService {
|
||||
groupId: string;
|
||||
type: string;
|
||||
name: string;
|
||||
peerUserId?: string | null;
|
||||
botUsername?: string | null;
|
||||
isE2E?: boolean;
|
||||
hasAvatar: boolean;
|
||||
updatedAt: Date;
|
||||
createdById: string | null;
|
||||
@@ -472,13 +880,22 @@ export class ChatService {
|
||||
}>;
|
||||
},
|
||||
familyRoleByUserId: Map<string, string>,
|
||||
lastMessage?: Awaited<ReturnType<ChatService['toMessage']>>
|
||||
lastMessage?: Awaited<ReturnType<ChatService['toMessage']>>,
|
||||
viewerId?: string
|
||||
) {
|
||||
const peerMember =
|
||||
viewerId && (room.type === 'DIRECT' || room.type === 'BOT' || room.type === 'E2E')
|
||||
? room.members.find((member) => member.userId !== viewerId)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: room.id,
|
||||
groupId: room.groupId,
|
||||
type: room.type,
|
||||
name: room.name,
|
||||
name: peerMember?.user.displayName ?? room.name,
|
||||
peerUserId: peerMember?.userId ?? (room.peerUserId && room.peerUserId !== viewerId ? room.peerUserId : undefined),
|
||||
botUsername: room.botUsername ?? undefined,
|
||||
isE2E: room.isE2E ?? false,
|
||||
hasAvatar: room.hasAvatar,
|
||||
createdById: room.createdById ?? undefined,
|
||||
updatedAt: room.updatedAt.toISOString(),
|
||||
@@ -505,7 +922,7 @@ export class ChatService {
|
||||
});
|
||||
const familyMembers = await this.prisma.familyMember.findMany({ where: { groupId: full!.groupId } });
|
||||
const familyRoleByUserId = new Map(familyMembers.map((member) => [member.userId, member.role]));
|
||||
return this.formatRoom(full!, familyRoleByUserId);
|
||||
return this.formatRoom(full!, familyRoleByUserId, undefined, userId);
|
||||
}
|
||||
|
||||
private async getRoomReadContext(roomId: string): Promise<ReadContextMember[]> {
|
||||
@@ -548,7 +965,11 @@ export class ChatService {
|
||||
) {
|
||||
const sender = await this.prisma.user.findUnique({ where: { id: senderId } });
|
||||
const preview =
|
||||
message.type === 'TEXT' || message.type === 'EMOJI'
|
||||
message.isEncrypted
|
||||
? '🔒 Зашифрованное сообщение'
|
||||
: message.type === 'SYSTEM'
|
||||
? (message.content ?? 'Системное сообщение')
|
||||
: message.type === 'TEXT' || message.type === 'EMOJI'
|
||||
? (message.content ?? '')
|
||||
: message.type === 'POLL'
|
||||
? '📊 Опрос'
|
||||
@@ -590,6 +1011,7 @@ export class ChatService {
|
||||
senderId: string;
|
||||
type: string;
|
||||
content: string | null;
|
||||
isEncrypted?: boolean;
|
||||
replyToId: string | null;
|
||||
storageKey: string | null;
|
||||
mimeType: string | null;
|
||||
@@ -628,6 +1050,7 @@ export class ChatService {
|
||||
senderVerificationIcon: message.sender.isVerified ? resolveVerificationIcon(message.sender.verificationIcon) : undefined,
|
||||
type: message.type,
|
||||
content: isDeleted ? undefined : message.content ?? undefined,
|
||||
isEncrypted: Boolean(message.isEncrypted),
|
||||
replyToId: message.replyToId ?? undefined,
|
||||
storageKey: isDeleted ? undefined : message.storageKey ?? undefined,
|
||||
mimeType: isDeleted ? undefined : message.mimeType ?? undefined,
|
||||
@@ -636,6 +1059,7 @@ export class ChatService {
|
||||
editedAt: message.editedAt?.toISOString(),
|
||||
isDeleted,
|
||||
readByAll: message.senderId === viewerId ? readByAll : undefined,
|
||||
reactions: isDeleted ? [] : this.buildReactionResponses(message.metadata, viewerId),
|
||||
poll: !isDeleted && message.poll
|
||||
? {
|
||||
id: message.poll.id,
|
||||
@@ -653,4 +1077,132 @@ export class ChatService {
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
private buildReactionResponses(metadata: unknown, viewerId: string) {
|
||||
const meta = metadata as { reactions?: Record<string, string[]> } | null;
|
||||
const reactions = meta?.reactions;
|
||||
if (!reactions) return [];
|
||||
return Object.entries(reactions)
|
||||
.filter(([, userIds]) => userIds.length > 0)
|
||||
.map(([emoji, userIds]) => ({
|
||||
emoji,
|
||||
count: userIds.length,
|
||||
reactedByMe: userIds.includes(viewerId)
|
||||
}));
|
||||
}
|
||||
|
||||
private async findPairRoom(groupId: string, userA: string, userB: string, type: 'DIRECT' | 'BOT' | 'E2E') {
|
||||
const rooms = await this.prisma.chatRoom.findMany({
|
||||
where: { groupId, type },
|
||||
include: { members: true }
|
||||
});
|
||||
return rooms.find((room) => {
|
||||
const ids = new Set(room.members.map((member) => member.userId));
|
||||
return ids.size === 2 && ids.has(userA) && ids.has(userB);
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureDirectRoom(groupId: string, userA: string, userB: string, peerName: string) {
|
||||
const existing = await this.findPairRoom(groupId, userA, userB, 'DIRECT');
|
||||
if (existing) return existing;
|
||||
|
||||
return this.prisma.chatRoom.create({
|
||||
data: {
|
||||
groupId,
|
||||
type: 'DIRECT',
|
||||
name: peerName,
|
||||
peerUserId: userB,
|
||||
isE2E: false,
|
||||
members: {
|
||||
create: [{ userId: userA }, { userId: userB }]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureBotRoom(
|
||||
groupId: string,
|
||||
humanUserId: string,
|
||||
botUserId: string,
|
||||
botUsername: string,
|
||||
botDisplayName: string
|
||||
) {
|
||||
const existing = await this.findPairRoom(groupId, humanUserId, botUserId, 'BOT');
|
||||
if (existing) {
|
||||
if (!existing.botUsername) {
|
||||
await this.prisma.chatRoom.update({
|
||||
where: { id: existing.id },
|
||||
data: { botUsername, peerUserId: botUserId, name: botDisplayName }
|
||||
});
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
return this.prisma.chatRoom.create({
|
||||
data: {
|
||||
groupId,
|
||||
type: 'BOT',
|
||||
name: botDisplayName,
|
||||
peerUserId: botUserId,
|
||||
botUsername,
|
||||
isE2E: false,
|
||||
members: {
|
||||
create: [{ userId: humanUserId }, { userId: botUserId }]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async getBotRoomPreview(userId: string, botUsername: string) {
|
||||
const bot = await this.prisma.bot.findFirst({
|
||||
where: {
|
||||
OR: [{ username: botUsername }, { username: `${botUsername.replace(/_bot$/i, '')}_bot` }]
|
||||
},
|
||||
select: { id: true, name: true }
|
||||
});
|
||||
if (!bot) return undefined;
|
||||
|
||||
const chat = await this.prisma.botChat.findUnique({
|
||||
where: { botId_recipientUserId: { botId: bot.id, recipientUserId: userId } }
|
||||
});
|
||||
if (!chat) return undefined;
|
||||
|
||||
const [lastInbound, lastOutbound] = await Promise.all([
|
||||
this.prisma.botInboundMessage.findFirst({
|
||||
where: { botChatId: chat.id },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
}),
|
||||
this.prisma.botMessage.findFirst({
|
||||
where: { botChatId: chat.id },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
]);
|
||||
|
||||
const candidates = [lastInbound, lastOutbound].filter(Boolean) as Array<{ createdAt: Date; text: string | null }>;
|
||||
if (!candidates.length) return undefined;
|
||||
|
||||
const latest = candidates.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]!;
|
||||
return {
|
||||
id: `bot-preview-${chat.id}`,
|
||||
roomId: '',
|
||||
senderId: userId,
|
||||
senderName: bot.name,
|
||||
senderHasAvatar: false,
|
||||
senderIsVerified: true,
|
||||
senderVerificationIcon: resolveVerificationIcon('bot'),
|
||||
type: 'TEXT',
|
||||
content: latest.text ?? '',
|
||||
isEncrypted: false,
|
||||
replyToId: undefined,
|
||||
storageKey: undefined,
|
||||
mimeType: undefined,
|
||||
metadataJson: undefined,
|
||||
createdAt: latest.createdAt.toISOString(),
|
||||
editedAt: undefined,
|
||||
isDeleted: false,
|
||||
readByAll: undefined,
|
||||
reactions: [],
|
||||
poll: undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface PublicUser {
|
||||
isVerified?: boolean;
|
||||
verificationIcon?: string;
|
||||
canVerifyUsers?: boolean;
|
||||
canManageBots?: boolean;
|
||||
}
|
||||
|
||||
export interface VerifyPinCommand {
|
||||
|
||||
@@ -7,6 +7,11 @@ import { SettingsService } from './settings.service';
|
||||
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { MinioService } from '../infra/minio.service';
|
||||
import { BotFatherSeedService } from './bot/bot-father-seed.service';
|
||||
import { BotFatherService } from './bot/bot-father.service';
|
||||
import { BOTFATHER_BOT_USERNAME, BOTFATHER_DISPLAY_NAME, BOTFATHER_USER_USERNAME } from './bot/bot-father.constants';
|
||||
import { normalizeBotUsername } from './bot/bot-token.util';
|
||||
import { HUMAN_USER_WHERE, isProtectedSystemAccount } from './system-account.util';
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +31,11 @@ export class FamilyService {
|
||||
|
||||
private readonly notifications: NotificationsService,
|
||||
|
||||
private readonly minio: MinioService
|
||||
private readonly minio: MinioService,
|
||||
|
||||
private readonly botFatherSeed: BotFatherSeedService,
|
||||
|
||||
private readonly botFather: BotFatherService
|
||||
|
||||
) {}
|
||||
|
||||
@@ -58,7 +67,7 @@ export class FamilyService {
|
||||
|
||||
},
|
||||
|
||||
include: { members: { include: { user: true } } }
|
||||
include: { members: { include: this.memberUserInclude() } }
|
||||
|
||||
});
|
||||
|
||||
@@ -102,7 +111,7 @@ export class FamilyService {
|
||||
|
||||
where: { OR: [{ ownerId: userId }, { members: { some: { userId } } }] },
|
||||
|
||||
include: { members: { include: { user: true } } },
|
||||
include: { members: { include: this.memberUserInclude() } },
|
||||
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
|
||||
@@ -146,7 +155,7 @@ export class FamilyService {
|
||||
|
||||
data: { name: trimmedName },
|
||||
|
||||
include: { members: { include: { user: true } } }
|
||||
include: { members: { include: this.memberUserInclude() } }
|
||||
|
||||
});
|
||||
|
||||
@@ -174,7 +183,7 @@ export class FamilyService {
|
||||
|
||||
where: { ownerId: userId },
|
||||
|
||||
include: { members: { include: { user: true } } }
|
||||
include: { members: { include: this.memberUserInclude() } }
|
||||
|
||||
});
|
||||
|
||||
@@ -322,6 +331,21 @@ export class FamilyService {
|
||||
|
||||
}
|
||||
|
||||
const targetUser = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { isSystemAccount: true, linkedBotId: true }
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
throw new BadRequestException('Пользователь не найден');
|
||||
}
|
||||
|
||||
if (isProtectedSystemAccount(targetUser)) {
|
||||
if (role !== 'bot' || !targetUser.linkedBotId) {
|
||||
throw new BadRequestException('Системные учётные записи нельзя добавить в семью напрямую');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const member = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -332,7 +356,7 @@ export class FamilyService {
|
||||
|
||||
for (const room of rooms) {
|
||||
|
||||
if (room.type === 'GENERAL') {
|
||||
if (room.type === 'GENERAL' && role !== 'bot') {
|
||||
|
||||
await tx.chatRoomMember.upsert({
|
||||
|
||||
@@ -354,8 +378,53 @@ export class FamilyService {
|
||||
|
||||
|
||||
|
||||
return this.toMember(member);
|
||||
const user = await this.loadMemberUser(member.userId);
|
||||
|
||||
return this.toMember({ ...member, user: user ?? undefined });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async addBotFatherToFamily(requesterId: string, groupId: string) {
|
||||
|
||||
const group = await this.getGroupWithMembers(groupId);
|
||||
|
||||
this.assertMember(group, requesterId);
|
||||
|
||||
const botFatherUserId = await this.botFatherSeed.getBotFatherUserId();
|
||||
|
||||
if (group.members.some((member) => member.userId === botFatherUserId)) {
|
||||
|
||||
throw new BadRequestException('BotFather уже добавлен в эту семью');
|
||||
|
||||
}
|
||||
|
||||
return this.addMember(groupId, botFatherUserId, 'bot');
|
||||
|
||||
}
|
||||
|
||||
async addBotToFamily(requesterId: string, groupId: string, botId: string) {
|
||||
const group = await this.getGroupWithMembers(groupId);
|
||||
this.assertMember(group, requesterId);
|
||||
|
||||
const bot = await this.prisma.bot.findUnique({
|
||||
where: { id: botId },
|
||||
select: { id: true, isActive: true, username: true, name: true }
|
||||
});
|
||||
if (!bot) {
|
||||
throw new NotFoundException('Бот не найден');
|
||||
}
|
||||
if (!bot.isActive) {
|
||||
throw new BadRequestException('Бот деактивирован и не может быть добавлен в семью');
|
||||
}
|
||||
|
||||
const { userId: botSystemUserId } = await this.botFather.ensureBotSystemUser(bot.id);
|
||||
if (group.members.some((member) => member.userId === botSystemUserId)) {
|
||||
throw new BadRequestException('Этот бот уже добавлен в семью');
|
||||
}
|
||||
|
||||
return this.addMember(groupId, botSystemUserId, 'bot');
|
||||
}
|
||||
|
||||
|
||||
@@ -414,7 +483,7 @@ export class FamilyService {
|
||||
|
||||
const invitee = options.inviteeUserId
|
||||
? await this.prisma.user.findFirst({
|
||||
where: { id: options.inviteeUserId, status: 'ACTIVE', deletedAt: null }
|
||||
where: { id: options.inviteeUserId, status: 'ACTIVE', deletedAt: null, ...HUMAN_USER_WHERE }
|
||||
})
|
||||
: options.target
|
||||
? await this.findUserByContact(options.target)
|
||||
@@ -424,6 +493,10 @@ export class FamilyService {
|
||||
throw new BadRequestException('Пользователь не найден. Выберите его из результатов поиска.');
|
||||
}
|
||||
|
||||
if (isProtectedSystemAccount(invitee)) {
|
||||
throw new BadRequestException('BotFather добавляется через отдельное действие «Добавить BotFather»');
|
||||
}
|
||||
|
||||
if (invitee.id === requesterId) {
|
||||
throw new BadRequestException('Нельзя пригласить самого себя');
|
||||
}
|
||||
@@ -476,44 +549,137 @@ export class FamilyService {
|
||||
}
|
||||
|
||||
const excludedIds = [...group.members.map((member) => member.userId), requesterId];
|
||||
const excludedBotUsernames = new Set(
|
||||
group.members
|
||||
.map((member) => member.user?.linkedBot?.username)
|
||||
.filter((username): username is string => Boolean(username))
|
||||
);
|
||||
const botFatherUserId = await this.botFatherSeed.getBotFatherUserId();
|
||||
const botFatherMatches =
|
||||
/bot\s*father|бот\s*father|botfather/i.test(trimmed) &&
|
||||
!excludedIds.includes(botFatherUserId) &&
|
||||
!excludedBotUsernames.has(BOTFATHER_BOT_USERNAME);
|
||||
const digits = trimmed.replace(/\D/g, '');
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
id: { notIn: excludedIds },
|
||||
OR: [
|
||||
{ displayName: { contains: trimmed, mode: 'insensitive' } },
|
||||
{ username: { contains: trimmed, mode: 'insensitive' } },
|
||||
...(trimmed.includes('@') ? [{ email: { contains: trimmed, mode: 'insensitive' as const } }] : []),
|
||||
...(digits.length >= 4 ? [{ phone: { contains: digits } }] : [])
|
||||
]
|
||||
},
|
||||
orderBy: { displayName: 'asc' },
|
||||
take: 8,
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
username: true,
|
||||
avatarStorageKey: true,
|
||||
const normalizedBotQuery = normalizeBotUsername(trimmed.replace(/^@/, ''));
|
||||
|
||||
const [users, matchedBots] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
isSystemAccount: false,
|
||||
linkedBotId: null,
|
||||
id: { notIn: excludedIds },
|
||||
OR: [
|
||||
{ displayName: { contains: trimmed, mode: 'insensitive' } },
|
||||
{ username: { contains: trimmed, mode: 'insensitive' } },
|
||||
...(trimmed.includes('@') ? [{ email: { contains: trimmed, mode: 'insensitive' as const } }] : []),
|
||||
...(digits.length >= 4 ? [{ phone: { contains: digits } }] : [])
|
||||
]
|
||||
},
|
||||
orderBy: { displayName: 'asc' },
|
||||
take: 8,
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
username: true,
|
||||
avatarStorageKey: true,
|
||||
isVerified: true,
|
||||
verificationIcon: true
|
||||
}
|
||||
}),
|
||||
normalizedBotQuery.length >= 2
|
||||
? this.prisma.bot.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ name: { contains: trimmed, mode: 'insensitive' } },
|
||||
{ username: { contains: normalizedBotQuery, mode: 'insensitive' } }
|
||||
]
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
take: 8,
|
||||
include: {
|
||||
owner: { select: { id: true, displayName: true } },
|
||||
linkedSystemUser: { select: { id: true } }
|
||||
}
|
||||
})
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
const botCandidates: Array<{
|
||||
id: string;
|
||||
botId: string;
|
||||
displayName: string;
|
||||
username?: string;
|
||||
hasAvatar: boolean;
|
||||
isVerified: boolean;
|
||||
verificationIcon?: string;
|
||||
isBot: boolean;
|
||||
botUsername: string;
|
||||
ownerDisplayName?: string;
|
||||
}> = [];
|
||||
|
||||
for (const bot of matchedBots) {
|
||||
if (excludedBotUsernames.has(bot.username)) continue;
|
||||
const { userId } = bot.linkedSystemUser
|
||||
? { userId: bot.linkedSystemUser.id }
|
||||
: await this.botFather.ensureBotSystemUser(bot.id);
|
||||
if (excludedIds.includes(userId)) continue;
|
||||
botCandidates.push({
|
||||
id: userId,
|
||||
botId: bot.id,
|
||||
displayName: bot.name,
|
||||
username: `@${bot.username}`,
|
||||
hasAvatar: false,
|
||||
isVerified: true,
|
||||
verificationIcon: true
|
||||
}
|
||||
});
|
||||
verificationIcon: resolveVerificationIcon('bot'),
|
||||
isBot: true,
|
||||
botUsername: bot.username,
|
||||
ownerDisplayName: bot.owner.displayName
|
||||
});
|
||||
}
|
||||
|
||||
const humanCandidates = users.map((user) => ({
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
username: user.username,
|
||||
hasAvatar: Boolean(user.avatarStorageKey),
|
||||
isVerified: user.isVerified,
|
||||
verificationIcon: user.isVerified ? resolveVerificationIcon(user.verificationIcon) : undefined,
|
||||
isBot: false,
|
||||
botUsername: undefined,
|
||||
botId: undefined,
|
||||
ownerDisplayName: undefined
|
||||
}));
|
||||
|
||||
return {
|
||||
users: users.map((user) => ({
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
username: user.username,
|
||||
hasAvatar: Boolean(user.avatarStorageKey),
|
||||
isVerified: user.isVerified,
|
||||
verificationIcon: user.isVerified ? resolveVerificationIcon(user.verificationIcon) : undefined
|
||||
}))
|
||||
users: [
|
||||
...(botFatherMatches
|
||||
? [
|
||||
{
|
||||
id: botFatherUserId,
|
||||
displayName: BOTFATHER_DISPLAY_NAME,
|
||||
email: undefined,
|
||||
phone: undefined,
|
||||
username: BOTFATHER_USER_USERNAME,
|
||||
hasAvatar: false,
|
||||
isVerified: true,
|
||||
verificationIcon: resolveVerificationIcon('bot'),
|
||||
isBot: true,
|
||||
botUsername: BOTFATHER_BOT_USERNAME,
|
||||
botId: undefined,
|
||||
ownerDisplayName: undefined
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...botCandidates,
|
||||
...humanCandidates.filter((candidate) => !botCandidates.some((bot) => bot.id === candidate.id))
|
||||
].slice(0, 12)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -702,7 +868,7 @@ export class FamilyService {
|
||||
|
||||
where: { id: groupId },
|
||||
|
||||
include: { members: { include: { user: true } } }
|
||||
include: { members: { include: this.memberUserInclude() } }
|
||||
|
||||
});
|
||||
|
||||
@@ -750,7 +916,7 @@ export class FamilyService {
|
||||
|
||||
if (trimmed.includes('@')) {
|
||||
return this.prisma.user.findFirst({
|
||||
where: { email: { equals: trimmed, mode: 'insensitive' }, status: 'ACTIVE', deletedAt: null }
|
||||
where: { email: { equals: trimmed, mode: 'insensitive' }, status: 'ACTIVE', deletedAt: null, ...HUMAN_USER_WHERE }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -760,6 +926,7 @@ export class FamilyService {
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
...HUMAN_USER_WHERE,
|
||||
OR: [{ phone: trimmed }, { phone: { endsWith: digits.slice(-10) } }]
|
||||
}
|
||||
});
|
||||
@@ -769,6 +936,7 @@ export class FamilyService {
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
...HUMAN_USER_WHERE,
|
||||
OR: [
|
||||
{ username: { equals: trimmed, mode: 'insensitive' } },
|
||||
{ displayName: { equals: trimmed, mode: 'insensitive' } }
|
||||
@@ -805,7 +973,14 @@ export class FamilyService {
|
||||
|
||||
createdAt: Date;
|
||||
|
||||
user?: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null };
|
||||
user?: {
|
||||
displayName: string;
|
||||
avatarStorageKey: string | null;
|
||||
isVerified: boolean;
|
||||
verificationIcon: string | null;
|
||||
linkedBotId?: string | null;
|
||||
linkedBot?: { username: string } | null;
|
||||
};
|
||||
|
||||
}>;
|
||||
|
||||
@@ -825,7 +1000,11 @@ export class FamilyService {
|
||||
|
||||
updatedAt: group.updatedAt.toISOString(),
|
||||
|
||||
members: group.members.map((member) => this.toMember(member))
|
||||
members: group.members.map((member) => this.toMember(member)),
|
||||
|
||||
botFatherAvailable: !group.members.some(
|
||||
(member) => member.user?.linkedBot?.username === BOTFATHER_BOT_USERNAME
|
||||
)
|
||||
|
||||
};
|
||||
|
||||
@@ -845,7 +1024,14 @@ export class FamilyService {
|
||||
|
||||
createdAt: Date;
|
||||
|
||||
user?: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null };
|
||||
user?: {
|
||||
displayName: string;
|
||||
avatarStorageKey: string | null;
|
||||
isVerified: boolean;
|
||||
verificationIcon: string | null;
|
||||
linkedBotId?: string | null;
|
||||
linkedBot?: { username: string } | null;
|
||||
};
|
||||
|
||||
}) {
|
||||
|
||||
@@ -867,6 +1053,10 @@ export class FamilyService {
|
||||
|
||||
verificationIcon: member.user?.isVerified ? resolveVerificationIcon(member.user?.verificationIcon) : undefined,
|
||||
|
||||
isBot: Boolean(member.user?.linkedBotId),
|
||||
|
||||
botUsername: member.user?.linkedBot?.username ?? undefined,
|
||||
|
||||
createdAt: member.createdAt.toISOString()
|
||||
|
||||
};
|
||||
@@ -875,6 +1065,40 @@ export class FamilyService {
|
||||
|
||||
|
||||
|
||||
private memberUserInclude() {
|
||||
|
||||
return {
|
||||
|
||||
user: {
|
||||
|
||||
include: {
|
||||
|
||||
linkedBot: { select: { username: true } }
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private loadMemberUser(userId: string) {
|
||||
|
||||
return this.prisma.user.findUnique({
|
||||
|
||||
where: { id: userId },
|
||||
|
||||
include: { linkedBot: { select: { username: true } } }
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private toInvite(invite: {
|
||||
|
||||
id: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { AccessService } from './access.service';
|
||||
import { ChatService } from './chat.service';
|
||||
import { MinioService } from '../infra/minio.service';
|
||||
|
||||
const AVATAR_ACCESS_TTL_SECONDS = 900;
|
||||
@@ -32,7 +33,8 @@ export class MediaService {
|
||||
private readonly minio: MinioService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly access: AccessService
|
||||
private readonly access: AccessService,
|
||||
private readonly chat: ChatService
|
||||
) {}
|
||||
|
||||
async createAvatarUploadUrl(userId: string, contentType: string) {
|
||||
@@ -259,6 +261,7 @@ export class MediaService {
|
||||
where: { id: roomId },
|
||||
data: { avatarStorageKey: storageKey, hasAvatar: true }
|
||||
});
|
||||
await this.chat.publishRoomAvatarChangedMessage(roomId, requesterId);
|
||||
return { roomId, hasAvatar: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { OAuthIssuerService } from './oauth-issuer.service';
|
||||
import { assertHumanAccount, HUMAN_USER_WHERE } from './system-account.util';
|
||||
|
||||
@Injectable()
|
||||
export class OAuthCoreService {
|
||||
@@ -15,6 +16,14 @@ export class OAuthCoreService {
|
||||
) {}
|
||||
|
||||
async authorize(command: { userId: string; clientId: string; redirectUri: string; scope: string; state?: string }) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: command.userId, deletedAt: null, status: 'ACTIVE', ...HUMAN_USER_WHERE }
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Пользователь не найден или заблокирован');
|
||||
}
|
||||
assertHumanAccount(user, { asAuth: true });
|
||||
|
||||
const client = await this.prisma.oAuthClient.findUnique({ where: { clientId: command.clientId } });
|
||||
if (!client?.isActive || !client.redirectUris.includes(command.redirectUri)) {
|
||||
throw new BadRequestException('OAuth приложение не найдено или redirect_uri запрещен');
|
||||
@@ -50,6 +59,7 @@ export class OAuthCoreService {
|
||||
throw new UnauthorizedException('Код авторизации недействителен');
|
||||
}
|
||||
await this.prisma.oAuthAuthorizationCode.update({ where: { id: authCode.id }, data: { consumedAt: new Date() } });
|
||||
assertHumanAccount(authCode.user, { asAuth: true });
|
||||
return this.issueOAuthTokens(authCode.user.id, client.clientId, authCode.scopes);
|
||||
}
|
||||
|
||||
@@ -67,10 +77,14 @@ export class OAuthCoreService {
|
||||
const payload = await this.jwt.verifyAsync<{ sub: string }>(accessToken, { secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET') });
|
||||
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
|
||||
if (!user) throw new UnauthorizedException('Пользователь не найден');
|
||||
assertHumanAccount(user, { asAuth: true });
|
||||
return { sub: user.id, email: user.email, phone: user.phone, name: user.displayName, picture: user.avatarUrl };
|
||||
}
|
||||
|
||||
private async issueOAuthTokens(userId: string, clientId: string, scopes: string[]) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException('Пользователь не найден');
|
||||
assertHumanAccount(user, { asAuth: true });
|
||||
const issuer = await this.oauthIssuer.resolveIssuer();
|
||||
const accessToken = await this.jwt.signAsync({ sub: userId, aud: clientId, scopes, typ: 'access_token' }, { secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer });
|
||||
const refreshToken = await this.jwt.signAsync({ sub: userId, aud: clientId, scopes, typ: 'refresh_token' }, { secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'), expiresIn: '30d', issuer });
|
||||
|
||||
@@ -103,6 +103,32 @@ export class ProfileService {
|
||||
|
||||
}
|
||||
|
||||
async setE2EPublicKey(userId: string, publicKey: string) {
|
||||
const trimmed = publicKey.trim();
|
||||
if (!trimmed || trimmed.length > 4096) {
|
||||
throw new BadRequestException('Некорректный публичный ключ E2E');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { e2ePublicKey: trimmed },
|
||||
include: { profile: true }
|
||||
});
|
||||
|
||||
return { userId: user.id, e2ePublicKey: user.e2ePublicKey ?? undefined };
|
||||
}
|
||||
|
||||
async getE2EPublicKey(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, e2ePublicKey: true }
|
||||
});
|
||||
if (!user) {
|
||||
throw new NotFoundException('Пользователь не найден');
|
||||
}
|
||||
return { userId: user.id, e2ePublicKey: user.e2ePublicKey ?? undefined };
|
||||
}
|
||||
|
||||
|
||||
|
||||
async updateAvatar(userId: string, avatarUrl?: string, avatarStorageKey?: string) {
|
||||
@@ -684,7 +710,9 @@ export class ProfileService {
|
||||
|
||||
hasPassword: Boolean(user.passwordHash),
|
||||
|
||||
deletionRequestedAt: (user as { deletionRequestedAt?: Date | null }).deletionRequestedAt?.toISOString()
|
||||
deletionRequestedAt: (user as { deletionRequestedAt?: Date | null }).deletionRequestedAt?.toISOString(),
|
||||
|
||||
e2ePublicKey: (user as { e2ePublicKey?: string | null }).e2ePublicKey ?? undefined
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SessionStatus } from '../generated/prisma/client';
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { isProtectedSystemAccount } from './system-account.util';
|
||||
|
||||
export interface SessionState {
|
||||
id: string;
|
||||
@@ -31,6 +32,14 @@ export class SessionService {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isProtectedSystemAccount(session.user)) {
|
||||
await this.prisma.session.update({
|
||||
where: { id: sessionId },
|
||||
data: { status: SessionStatus.REVOKED }
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const pinEnabled = Boolean(session.user.pinCode?.isEnabled);
|
||||
let pinVerified = session.pinVerified;
|
||||
let status = session.status;
|
||||
|
||||
43
apps/sso-core/src/domain/system-account.util.ts
Normal file
43
apps/sso-core/src/domain/system-account.util.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
export type SystemAccountProbe = {
|
||||
isSystemAccount?: boolean;
|
||||
linkedBotId?: string | null;
|
||||
};
|
||||
|
||||
export const RESERVED_SYSTEM_USERNAMES = new Set(['botfather', 'system-bot-owner']);
|
||||
|
||||
export function isProtectedSystemAccount(user: SystemAccountProbe | null | undefined): boolean {
|
||||
if (!user) return false;
|
||||
return Boolean(user.isSystemAccount || user.linkedBotId);
|
||||
}
|
||||
|
||||
export function assertHumanAccount(
|
||||
user: SystemAccountProbe | null | undefined,
|
||||
options?: { message?: string; asAuth?: boolean }
|
||||
): void {
|
||||
if (!isProtectedSystemAccount(user)) return;
|
||||
|
||||
const message =
|
||||
options?.message ??
|
||||
(options?.asAuth
|
||||
? 'Системные учётные записи не могут входить в систему'
|
||||
: 'Операция недоступна для системных учётных записей');
|
||||
|
||||
if (options?.asAuth) {
|
||||
throw new UnauthorizedException(message);
|
||||
}
|
||||
throw new ForbiddenException(message);
|
||||
}
|
||||
|
||||
export function assertNotReservedUsername(username: string | null | undefined): void {
|
||||
if (!username?.trim()) return;
|
||||
if (RESERVED_SYSTEM_USERNAMES.has(username.trim().toLowerCase())) {
|
||||
throw new BadRequestException('Этот username зарезервирован системой');
|
||||
}
|
||||
}
|
||||
|
||||
export const HUMAN_USER_WHERE = {
|
||||
isSystemAccount: false,
|
||||
linkedBotId: null
|
||||
} as const;
|
||||
@@ -82,7 +82,11 @@ export const DEFAULT_SYSTEM_SETTINGS = [
|
||||
key: 'SMS_GATEWAY_MESSAGE_TEMPLATE',
|
||||
value: '{{appname}}: код {{code}}. Действует {{expiry}} мин.',
|
||||
description: 'Шаблон текста SMS для Huawei HiLink с тегами {{appname}}, {{code}}, {{expiry}}'
|
||||
}
|
||||
},
|
||||
{ key: 'BOT_MAX_BOTS_PER_USER', value: '5', description: 'Максимальное количество ботов на одного пользователя' },
|
||||
{ key: 'BOT_API_RATE_LIMIT_PER_SECOND', value: '30', description: 'Лимит запросов Bot API на один токен в секунду' },
|
||||
{ key: 'BOT_WEBHOOK_TIMEOUT_MS', value: '10000', description: 'Таймаут HTTP-запроса webhook бота (мс)' },
|
||||
{ key: 'BOT_WEBHOOK_MAX_RETRIES', value: '3', description: 'Число повторов webhook с exponential backoff' }
|
||||
] as const;
|
||||
|
||||
const SECRET_SETTING_KEYS = new Set([
|
||||
|
||||
@@ -20,7 +20,7 @@ async function bootstrap() {
|
||||
app.connectMicroservice<MicroserviceOptions>({
|
||||
transport: Transport.GRPC,
|
||||
options: {
|
||||
package: ['auth', 'admin', 'rbac', 'security', 'profile', 'documents', 'addresses', 'identity', 'media', 'notifications', 'chat'],
|
||||
package: ['auth', 'admin', 'rbac', 'security', 'profile', 'documents', 'addresses', 'identity', 'media', 'notifications', 'chat', 'bot'],
|
||||
protoPath: [
|
||||
join(__dirname, '../../../shared/proto/auth.proto'),
|
||||
join(__dirname, '../../../shared/proto/admin.proto'),
|
||||
@@ -32,7 +32,8 @@ async function bootstrap() {
|
||||
join(__dirname, '../../../shared/proto/identity.proto'),
|
||||
join(__dirname, '../../../shared/proto/media.proto'),
|
||||
join(__dirname, '../../../shared/proto/notifications.proto'),
|
||||
join(__dirname, '../../../shared/proto/chat.proto')
|
||||
join(__dirname, '../../../shared/proto/chat.proto'),
|
||||
join(__dirname, '../../../shared/proto/bot.proto')
|
||||
],
|
||||
url: config.getOrThrow<string>('GRPC_URL')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user