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'; interface PollPayload { question: string; options: string[]; allowsMultiple?: boolean; isAnonymous?: boolean; } interface ReadContextMember { userId: string; lastReadAt: Date | null; } @Injectable() export class ChatService { constructor( private readonly prisma: PrismaService, private readonly family: FamilyService, 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 } }); generalRoom = await this.prisma.chatRoom.create({ data: { groupId, type: 'GENERAL', name: 'Общий чат', members: { create: members.filter((member) => member.role !== 'bot').map((member) => ({ userId: member.userId })) } } }); } const rooms = await this.prisma.chatRoom.findMany({ where: { groupId, members: { some: { userId } } }, include: { members: { include: { user: true } }, messages: { orderBy: { createdAt: 'desc' }, take: 1, include: { sender: true, poll: { include: { options: { include: { votes: true } } } } } } }, 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])); 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 pinDiff = Number(Boolean(b.isPinned)) - Number(Boolean(a.isPinned)); if (pinDiff !== 0) return pinDiff; if (a.isPinned && b.isPinned) { return (b.pinnedAt ?? '').localeCompare(a.pinnedAt ?? ''); } 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 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); } } // BOT-чаты создаются только для тех людей, которым явно выдали доступ к боту. // Не создаём комнаты "каждый человек x каждый бот", иначе бот становится видимым всей семье. } async ensureBotRoomForMember(groupId: string, requesterId: string, botUserId: string) { const group = await this.family.assertFamilyMember(groupId, requesterId); const botMember = group.members.find((member) => member.userId === botUserId); if (!botMember?.user?.linkedBotId || !botMember.user.linkedBot) { throw new BadRequestException('Бот не найден в этой семье'); } return this.ensureBotRoom( groupId, requesterId, botUserId, botMember.user.linkedBot.username, botMember.user.displayName ); } async createRoom(userId: string, groupId: string, name: string, memberUserIds: string[]) { const group = await this.family.assertFamilyMember(groupId, userId); const trimmedName = name.trim(); if (!trimmedName) { throw new BadRequestException('Укажите название группового чата'); } 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('Все участники чата должны состоять в семье'); } } const room = await this.prisma.chatRoom.create({ data: { groupId, type: 'GROUP', name: trimmedName, createdById: userId, members: { create: uniqueMembers.map((memberId) => ({ userId: memberId })) } }, 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 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) { const room = await this.getRoomForMember(roomId, userId); if (room.type === 'GENERAL') { throw new BadRequestException('В общий чат нельзя добавлять участников вручную'); } if (room.type === 'DIRECT' || room.type === 'E2E') { throw new BadRequestException('Нельзя изменять состав личного чата или чата с ботом'); } if (room.members.some((member) => member.userId === memberUserId)) { throw new BadRequestException('Пользователь уже состоит в чате'); } if (!room.group.members.some((member) => member.userId === memberUserId)) { throw new BadRequestException('Пользователь должен состоять в семье'); } if (room.type === 'BOT') { const targetUser = await this.prisma.user.findUnique({ where: { id: memberUserId }, select: { linkedBotId: true, isSystemAccount: true } }); if (!targetUser || targetUser.linkedBotId || targetUser.isSystemAccount) { throw new BadRequestException('В чат с ботом можно добавить только обычного участника семьи'); } } await this.prisma.chatRoomMember.create({ data: { roomId, userId: memberUserId } }); return this.getRoomResponse(roomId, userId); } async removeRoomMember(userId: string, roomId: string, memberUserId: string) { const room = await this.getRoomForMember(roomId, userId); if (room.type === 'GENERAL') { throw new BadRequestException('Нельзя удалить участника из общего чата'); } if (room.type === 'DIRECT' || room.type === 'E2E') { throw new BadRequestException('Нельзя изменять состав личного чата или чата с ботом'); } const isFamilyOwner = room.group.ownerId === userId; const isChatCreator = room.createdById === userId; if (room.type !== 'BOT' && !isFamilyOwner && !isChatCreator) { throw new ForbiddenException('Недостаточно прав для удаления участника из чата'); } if (room.type === 'BOT' && memberUserId === room.peerUserId) { throw new BadRequestException('Нельзя удалить бота из его чата'); } if (memberUserId === room.createdById && !isFamilyOwner) { throw new BadRequestException('Создателя чата может удалить только создатель семьи'); } if (memberUserId === userId && room.members.length <= (room.type === 'BOT' ? 2 : 1)) { throw new BadRequestException('В чате должен остаться хотя бы один участник'); } const membership = room.members.find((member) => member.userId === memberUserId); if (!membership) { throw new NotFoundException('Участник не найден в этом чате'); } await this.prisma.chatRoomMember.delete({ where: { roomId_userId: { roomId, userId: memberUserId } } }); return this.getRoomResponse(roomId, userId); } async updateRoomSettings(userId: string, roomId: string, name?: string, notificationsMuted?: boolean, pinned?: boolean) { const room = await this.getRoomForMember(roomId, userId); if (name !== undefined) { const trimmedName = name.trim(); if (!trimmedName) { throw new BadRequestException('Укажите название чата'); } 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) { await this.prisma.chatRoomMember.update({ where: { roomId_userId: { roomId, userId } }, data: { notificationsMuted } }); } if (pinned !== undefined) { await this.prisma.chatRoomMember.update({ where: { roomId_userId: { roomId, userId } }, data: { pinnedAt: pinned ? new Date() : null } }); } return this.getRoomResponse(roomId, userId); } async listMessages(userId: string, roomId: string, beforeMessageId?: string, limit = 50) { 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); let cursorDate: Date | undefined; if (beforeMessageId) { const cursor = await this.prisma.chatMessage.findFirst({ where: { id: beforeMessageId, roomId } }); if (cursor) cursorDate = cursor.createdAt; } const messages = await this.prisma.chatMessage.findMany({ where: { roomId, ...(cursorDate ? { createdAt: { lt: cursorDate } } : {}) }, include: { sender: true, poll: { include: { options: { include: { votes: true } } } } }, orderBy: { createdAt: 'desc' }, take }); const ordered = messages.reverse(); return { messages: await Promise.all(ordered.map((message) => this.toMessage(message, userId, readContext))) }; } async sendMessage( userId: string, roomId: string, type: string, content?: string, replyToId?: string, storageKey?: string, mimeType?: string, metadataJson?: string, 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('Неподдерживаемый тип сообщения'); } if (type === 'POLL') { if (!poll?.question?.trim() || !poll.options?.length) { throw new BadRequestException('Укажите вопрос и варианты опроса'); } if (poll.options.length < 2) { throw new BadRequestException('В опросе должно быть минимум 2 варианта'); } } else if (type !== 'IMAGE' && type !== 'AUDIO' && type !== 'VOICE' && type !== 'FILE') { if (!content?.trim()) { throw new BadRequestException('Сообщение не может быть пустым'); } } if ((type === 'IMAGE' || type === 'AUDIO' || type === 'VOICE' || type === 'FILE') && !storageKey) { throw new BadRequestException('Не указан файл для отправки'); } if (storageKey && !storageKey.startsWith(`chat/${roomId}/`)) { throw new BadRequestException('Некорректный ключ медиафайла'); } const metadata = metadataJson ? JSON.parse(metadataJson) : undefined; const message = await this.prisma.$transaction(async (tx) => { const created = await tx.chatMessage.create({ data: { roomId, senderId: userId, type, content: content?.trim() || null, isEncrypted: Boolean(isEncrypted), replyToId: replyToId || null, storageKey: storageKey || null, mimeType: mimeType || null, metadata: metadata ?? undefined }, include: { sender: true } }); if (type === 'POLL' && poll) { await tx.chatPoll.create({ data: { messageId: created.id, question: poll.question.trim(), allowsMultiple: poll.allowsMultiple ?? false, isAnonymous: poll.isAnonymous ?? false, options: { create: poll.options.map((text) => ({ text: text.trim() })).filter((item) => item.text) } } }); } await tx.chatRoom.update({ where: { id: roomId }, data: { updatedAt: new Date() } }); return created; }); const fullMessage = await this.prisma.chatMessage.findUnique({ where: { id: message.id }, include: { sender: true, poll: { include: { options: { include: { votes: true } } } } } }); const readContext = await this.getRoomReadContext(roomId); const response = await this.toMessage(fullMessage!, userId, readContext); await this.notifyRoomMembers(room, userId, response); 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 }, include: { room: { include: { members: true } } } }); if (!message || message.deletedAt) { throw new NotFoundException('Сообщение не найдено'); } if (message.senderId !== userId) { throw new ForbiddenException('Можно редактировать только свои сообщения'); } if (message.isEncrypted) { throw new BadRequestException('Зашифрованные сообщения нельзя редактировать'); } if (!['TEXT', 'EMOJI'].includes(message.type)) { throw new BadRequestException('Этот тип сообщения нельзя редактировать'); } const trimmed = content.trim(); if (!trimmed) { throw new BadRequestException('Сообщение не может быть пустым'); } await this.prisma.chatMessage.update({ where: { id: messageId }, data: { content: trimmed, editedAt: new Date() } }); const response = await this.loadMessageResponse(messageId, userId); await this.publishRoomEvent(message.room, 'chat_message_updated', response); return response; } async deleteMessage(userId: string, messageId: string) { const message = await this.prisma.chatMessage.findUnique({ where: { id: messageId }, include: { room: { include: { members: true } } } }); if (!message || message.deletedAt) { throw new NotFoundException('Сообщение не найдено'); } if (message.senderId !== userId) { throw new ForbiddenException('Можно удалять только свои сообщения'); } await this.prisma.chatMessage.update({ where: { id: messageId }, data: { deletedAt: new Date(), content: null, storageKey: null, mimeType: null, metadata: undefined } }); const response = await this.loadMessageResponse(messageId, userId); await this.publishRoomEvent(message.room, 'chat_message_deleted', response); return response; } async setMessagePinned(userId: string, messageId: string, pinned: boolean) { 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.type === 'BOT') { throw new BadRequestException('Сообщения бота закрепляются через Bot API'); } if (!message.room.members.some((member) => member.userId === userId)) { throw new ForbiddenException('Вы не состоите в этом чате'); } await this.prisma.chatMessage.update({ where: { id: messageId }, data: pinned ? { isPinned: true, pinnedAt: new Date(), pinnedById: userId } : { isPinned: false, pinnedAt: null, pinnedById: null } }); const response = await this.loadMessageResponse(messageId, userId); await this.publishRoomEvent(message.room, 'chat_message_updated', response); 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 | null) ?? {}; const reactions = { ...((meta.reactions as Record) ?? {}) }; 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>[] = []; 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(); if (lastMessageId) { const message = await this.prisma.chatMessage.findFirst({ where: { id: lastMessageId, roomId } }); if (message) { readAt = message.createdAt; } } await this.prisma.chatRoomMember.update({ where: { roomId_userId: { roomId, userId } }, data: { lastReadAt: readAt, lastReadMessageId: lastMessageId ?? null } }); for (const member of room.members) { if (member.userId === userId) continue; await this.notifications.publishRealtime(member.userId, 'chat_read_receipt', room.name, '', { roomId, userId, lastReadAt: readAt.toISOString(), lastReadMessageId: lastMessageId }); } return { count: 1 }; } async votePoll(userId: string, messageId: string, optionIds: string[]) { const message = await this.prisma.chatMessage.findUnique({ where: { id: messageId }, include: { poll: { include: { options: true } }, room: { include: { members: true, group: true } } } }); if (!message?.poll) { throw new NotFoundException('Опрос не найден'); } await this.getRoomForMember(message.roomId, userId); const validOptionIds = new Set(message.poll.options.map((option) => option.id)); for (const optionId of optionIds) { if (!validOptionIds.has(optionId)) { throw new BadRequestException('Некорректный вариант опроса'); } } if (!message.poll.allowsMultiple && optionIds.length > 1) { throw new BadRequestException('Можно выбрать только один вариант'); } await this.prisma.$transaction(async (tx) => { await tx.chatPollVote.deleteMany({ where: { userId, option: { pollId: message.poll!.id } } }); for (const optionId of optionIds) { await tx.chatPollVote.create({ data: { optionId, userId } }); } }); const updated = await this.prisma.chatMessage.findUnique({ where: { id: messageId }, include: { sender: true, poll: { include: { options: { include: { votes: true } } } } } }); return this.toMessage(updated!, userId, await this.getRoomReadContext(updated!.roomId)); } async setRoomNotificationsMuted(userId: string, roomId: string, muted: boolean) { await this.getRoomForMember(roomId, userId); await this.prisma.chatRoomMember.update({ where: { roomId_userId: { roomId, userId } }, data: { notificationsMuted: muted } }); return { count: 1 }; } async assertRoomMember(roomId: string, userId: string) { return this.getRoomForMember(roomId, userId); } private async getRoomForMember(roomId: string, userId: string) { const room = await this.prisma.chatRoom.findUnique({ where: { id: roomId }, include: { members: true, group: { include: { members: true } } } }); if (!room) { throw new NotFoundException('Чат не найден'); } if (!room.members.some((member) => member.userId === userId)) { throw new ForbiddenException('Вы не состоите в этом чате'); } return room; } private formatRoom( room: { id: string; groupId: string; type: string; name: string; peerUserId?: string | null; botUsername?: string | null; isE2E?: boolean; hasAvatar: boolean; updatedAt: Date; createdById: string | null; members: Array<{ id: string; userId: string; notificationsMuted: boolean; pinnedAt?: Date | null; user: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null; linkedBotId?: string | null }; }>; }, familyRoleByUserId: Map, lastMessage?: Awaited>, viewerId?: string ) { const viewerMembership = viewerId ? room.members.find((member) => member.userId === viewerId) : undefined; const peerMember = room.type === 'BOT' ? room.members.find((member) => Boolean(member.user.linkedBotId)) : viewerId && (room.type === 'DIRECT' || room.type === 'E2E') ? room.members.find((member) => member.userId !== viewerId) : undefined; return { id: room.id, groupId: room.groupId, type: room.type, 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(), isPinned: Boolean(viewerMembership?.pinnedAt), pinnedAt: viewerMembership?.pinnedAt?.toISOString(), members: room.members.map((member) => ({ id: member.id, userId: member.userId, displayName: member.user.displayName, hasAvatar: Boolean(member.user.avatarStorageKey), isVerified: member.user.isVerified, verificationIcon: member.user.isVerified ? resolveVerificationIcon(member.user.verificationIcon) : undefined, notificationsMuted: member.notificationsMuted, isPinned: Boolean(member.pinnedAt), pinnedAt: member.pinnedAt?.toISOString(), familyRole: familyRoleByUserId.get(member.userId) ?? 'member', isChatCreator: room.createdById === member.userId })), lastMessage }; } private async getRoomResponse(roomId: string, userId: string) { const room = await this.getRoomForMember(roomId, userId); const full = await this.prisma.chatRoom.findUnique({ where: { id: roomId }, include: { members: { include: { user: true } } } }); 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, undefined, userId); } private async getRoomReadContext(roomId: string): Promise { const members = await this.prisma.chatRoomMember.findMany({ where: { roomId } }); return members.map((member) => ({ userId: member.userId, lastReadAt: member.lastReadAt })); } private async loadMessageResponse(messageId: string, viewerId: string) { const message = await this.prisma.chatMessage.findUnique({ where: { id: messageId }, include: { sender: true, poll: { include: { options: { include: { votes: true } } } } } }); if (!message) { throw new NotFoundException('Сообщение не найдено'); } const readContext = await this.getRoomReadContext(message.roomId); return this.toMessage(message, viewerId, readContext); } private async publishRoomEvent( room: { id: string; name: string; members: Array<{ userId: string }> }, eventType: string, message: Awaited> ) { for (const member of room.members) { await this.notifications.publishRealtime(member.userId, eventType, room.name, '', { roomId: room.id, message }); } } private async notifyRoomMembers( room: { id: string; name: string; members: Array<{ userId: string; notificationsMuted: boolean }> }, senderId: string, message: Awaited> ) { const sender = await this.prisma.user.findUnique({ where: { id: senderId } }); const preview = message.isEncrypted ? '🔒 Зашифрованное сообщение' : message.type === 'SYSTEM' ? (message.content ?? 'Системное сообщение') : message.type === 'TEXT' || message.type === 'EMOJI' ? (message.content ?? '') : message.type === 'POLL' ? '📊 Опрос' : message.type === 'VOICE' ? '🎤 Голосовое' : message.type === 'AUDIO' ? '🎵 Аудио' : message.type === 'FILE' ? '📄 Файл' : message.type === 'IMAGE' ? '📷 Фото' : '📎 Медиа'; for (const member of room.members) { if (member.userId === senderId) continue; await this.notifications.publishRealtime(member.userId, 'chat_message', room.name, preview.slice(0, 120), { roomId: room.id, message }); } for (const member of room.members) { if (member.userId === senderId || member.notificationsMuted) continue; await this.notifications.create( member.userId, 'chat_message', room.name, `${sender?.displayName ?? 'Участник'}: ${preview.slice(0, 120)}`, { roomId: room.id, messageId: message.id }, { skipPublish: true } ); } } private async toMessage( message: { id: string; roomId: string; senderId: string; type: string; content: string | null; isEncrypted?: boolean; replyToId: string | null; storageKey: string | null; mimeType: string | null; metadata: unknown; isPinned?: boolean; pinnedAt?: Date | null; pinnedById?: string | null; createdAt: Date; editedAt?: Date | null; deletedAt?: Date | null; sender: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null }; poll?: { id: string; question: string; allowsMultiple: boolean; isAnonymous: boolean; closedAt: Date | null; options: Array<{ id: string; text: string; votes: Array<{ userId: string }> }>; } | null; }, viewerId: string, readContext?: ReadContextMember[] ) { const isDeleted = Boolean(message.deletedAt); let readByAll = false; if (readContext && message.senderId === viewerId && !isDeleted) { const others = readContext.filter((member) => member.userId !== message.senderId); readByAll = others.length > 0 && others.every((member) => member.lastReadAt && member.lastReadAt >= message.createdAt); } return { id: message.id, roomId: message.roomId, senderId: message.senderId, senderName: message.sender.displayName, senderHasAvatar: Boolean(message.sender.avatarStorageKey), senderIsVerified: message.sender.isVerified, 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, metadataJson: isDeleted ? undefined : message.metadata ? JSON.stringify(message.metadata) : undefined, createdAt: message.createdAt.toISOString(), editedAt: message.editedAt?.toISOString(), isDeleted, readByAll: message.senderId === viewerId ? readByAll : undefined, reactions: isDeleted ? [] : this.buildReactionResponses(message.metadata, viewerId), isPinned: !isDeleted && Boolean(message.isPinned), pinnedAt: !isDeleted ? message.pinnedAt?.toISOString() : undefined, pinnedById: !isDeleted ? message.pinnedById ?? undefined : undefined, poll: !isDeleted && message.poll ? { id: message.poll.id, question: message.poll.question, allowsMultiple: message.poll.allowsMultiple, isAnonymous: message.poll.isAnonymous, closedAt: message.poll.closedAt?.toISOString(), options: message.poll.options.map((option) => ({ id: option.id, text: option.text, voteCount: option.votes.length, votedByMe: option.votes.some((vote) => vote.userId === viewerId) })) } : undefined }; } private buildReactionResponses(metadata: unknown, viewerId: string) { const meta = metadata as { reactions?: Record } | 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, createdById: humanUserId, 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: [], isPinned: false, pinnedAt: undefined, pinnedById: undefined, poll: undefined }; } }