global update and global fix
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user