1281 lines
46 KiB
TypeScript
1281 lines
46 KiB
TypeScript
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
|
||
};
|
||
}
|
||
}
|