Files
IdP/apps/sso-core/src/domain/chat.service.ts
2026-06-26 16:43:17 +03:00

1281 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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();
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<string, string>,
lastMessage?: Awaited<ReturnType<ChatService['toMessage']>>,
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<ReadContextMember[]> {
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<ReturnType<ChatService['toMessage']>>
) {
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<ReturnType<ChatService['toMessage']>>
) {
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<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,
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
};
}
}