fix docker for tauri app

This commit is contained in:
lendry
2026-06-26 16:43:17 +03:00
parent f1068edc89
commit 3ab48d8537
11 changed files with 220 additions and 76 deletions

View File

@@ -36,9 +36,9 @@ export class BotController {
@Get('by-username/:botRef/messages') @Get('by-username/:botRef/messages')
@ApiOperation({ summary: 'История чата с ботом', description: 'Возвращает сообщения пользователя с ботом в хронологическом порядке.' }) @ApiOperation({ summary: 'История чата с ботом', description: 'Возвращает сообщения пользователя с ботом в хронологическом порядке.' })
listBotMessages(@Headers('authorization') authorization: string | undefined, @Param('botRef') botRef: string) { listBotMessages(@Headers('authorization') authorization: string | undefined, @Param('botRef') botRef: string, @Query('roomId') roomId?: string) {
return this.auth(authorization).then((userId) => return this.auth(authorization).then((userId) =>
firstValueFrom(this.core.bot.ListBotChatMessages({ userId, botRef })) firstValueFrom(this.core.bot.ListBotChatMessages({ userId, botRef, roomId }))
); );
} }
@@ -54,7 +54,7 @@ export class BotController {
@Body() dto: SubmitBotMessageDto @Body() dto: SubmitBotMessageDto
) { ) {
return this.auth(authorization).then((senderUserId) => return this.auth(authorization).then((senderUserId) =>
firstValueFrom(this.core.bot.SubmitBotInboundMessage({ senderUserId, botRef, text: dto.text })) firstValueFrom(this.core.bot.SubmitBotInboundMessage({ senderUserId, botRef, text: dto.text, roomId: dto.roomId }))
); );
} }

View File

@@ -91,6 +91,10 @@ export class SubmitBotMessageDto {
@IsString() @IsString()
@MinLength(1, { message: 'Текст сообщения не может быть пустым' }) @MinLength(1, { message: 'Текст сообщения не может быть пустым' })
text!: string; text!: string;
@IsOptional()
@IsString()
roomId?: string;
} }
export class SubmitBotCallbackDto { export class SubmitBotCallbackDto {

View File

@@ -46,6 +46,7 @@ interface FamilyBotChatProps {
botRef: string; botRef: string;
botName: string; botName: string;
token: string | null; token: string | null;
roomId?: string;
className?: string; className?: string;
inputPlaceholder?: string; inputPlaceholder?: string;
onOpenMiniApp?: (url: string) => void; onOpenMiniApp?: (url: string) => void;
@@ -56,6 +57,7 @@ export function FamilyBotChat({
botRef, botRef,
botName, botName,
token, token,
roomId,
className, className,
inputPlaceholder = 'Сообщение боту', inputPlaceholder = 'Сообщение боту',
onOpenMiniApp, onOpenMiniApp,
@@ -98,7 +100,7 @@ export function FamilyBotChat({
if (!token) return; if (!token) return;
setLoading(true); setLoading(true);
try { try {
const response = await fetchBotChatMessages(botRef, token); const response = await fetchBotChatMessages(botRef, token, roomId);
setMessages(response.messages ?? []); setMessages(response.messages ?? []);
const menuButton = resolveComposerMenuButton({ const menuButton = resolveComposerMenuButton({
composerMenuButtonJson: response.composerMenuButtonJson, composerMenuButtonJson: response.composerMenuButtonJson,
@@ -119,7 +121,7 @@ export function FamilyBotChat({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [botRef, onBotMetaChange, token]); }, [botRef, onBotMetaChange, roomId, token]);
useEffect(() => { useEffect(() => {
void loadMessages(); void loadMessages();
@@ -240,7 +242,7 @@ export function FamilyBotChat({
} }
]); ]);
try { try {
await sendBotMessage(botRef, text, token); await sendBotMessage(botRef, text, token, roomId);
await loadMessages(); await loadMessages();
} finally { } finally {
setSending(false); setSending(false);

View File

@@ -340,7 +340,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
const myMembership = activeRoom?.members.find((member) => member.userId === user?.id); const myMembership = activeRoom?.members.find((member) => member.userId === user?.id);
const isFamilyOwner = group?.ownerId === user?.id; const isFamilyOwner = group?.ownerId === user?.id;
const isChatCreator = activeRoom?.createdById === user?.id; const isChatCreator = activeRoom?.createdById === user?.id;
const canManageChatMembers = Boolean(isFamilyOwner || isChatCreator); const canManageChatMembers = Boolean(isFamilyOwner || isChatCreator || activeRoomIsBot);
const canManageActiveBot = Boolean( const canManageActiveBot = Boolean(
activeRoomIsBot && botChatMeta?.manageWebAppUrl && user?.id && botChatMeta.botOwnerId === user.id activeRoomIsBot && botChatMeta?.manageWebAppUrl && user?.id && botChatMeta.botOwnerId === user.id
); );
@@ -1518,6 +1518,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
botRef={activeRoom.botUsername} botRef={activeRoom.botUsername}
botName={activeRoomTitle} botName={activeRoomTitle}
token={token} token={token}
roomId={activeRoom.id}
onBotMetaChange={setBotChatMeta} onBotMetaChange={setBotChatMeta}
onOpenMiniApp={(url) => { onOpenMiniApp={(url) => {
setMiniAppTitle('Mini App'); setMiniAppTitle('Mini App');
@@ -2142,8 +2143,9 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
const presence = presenceByUserId.get(member.userId); const presence = presenceByUserId.get(member.userId);
const canRemoveFromChat = const canRemoveFromChat =
canManageChatMembers && canManageChatMembers &&
activeRoom.type === 'GROUP' && (activeRoom.type === 'GROUP' || activeRoom.type === 'BOT') &&
member.userId !== user?.id && member.userId !== user?.id &&
member.userId !== activeRoom.peerUserId &&
!(member.isChatCreator && !isFamilyOwner); !(member.isChatCreator && !isFamilyOwner);
const canRemoveFromFamily = isFamilyOwner && member.familyRole !== 'owner' && familyMember; const canRemoveFromFamily = isFamilyOwner && member.familyRole !== 'owner' && familyMember;
@@ -2183,7 +2185,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
); );
})} })}
</div> </div>
{activeRoom?.type === 'GROUP' && familyMembersNotInRoom.length > 0 ? ( {(activeRoom?.type === 'GROUP' || activeRoom?.type === 'BOT') && familyMembersNotInRoom.length > 0 ? (
<Button className="mt-4 w-full rounded-xl" onClick={() => { <Button className="mt-4 w-full rounded-xl" onClick={() => {
setMembersOpen(false); setMembersOpen(false);
setAddMembersOpen(true); setAddMembersOpen(true);

View File

@@ -897,7 +897,8 @@ export async function sendFamilyInvite(groupId: string, payload: { inviteeUserId
return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token); return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token);
} }
export async function fetchBotChatMessages(botRef: string, token?: string | null) { export async function fetchBotChatMessages(botRef: string, token?: string | null, roomId?: string) {
const suffix = roomId ? `?roomId=${encodeURIComponent(roomId)}` : '';
return apiFetch<{ return apiFetch<{
messages?: BotChatMessage[]; messages?: BotChatMessage[];
botUsername?: string; botUsername?: string;
@@ -907,13 +908,13 @@ export async function fetchBotChatMessages(botRef: string, token?: string | null
composerWebAppUrl?: string; composerWebAppUrl?: string;
composerMenuButtonJson?: string; composerMenuButtonJson?: string;
manageWebAppUrl?: string; manageWebAppUrl?: string;
}>(`/bots/by-username/${encodeURIComponent(botRef)}/messages`, {}, token); }>(`/bots/by-username/${encodeURIComponent(botRef)}/messages${suffix}`, {}, token);
} }
export async function sendBotMessage(botRef: string, text: string, token?: string | null) { export async function sendBotMessage(botRef: string, text: string, token?: string | null, roomId?: string) {
return apiFetch<{ updateId?: number; messageId?: number; chatId?: string }>( return apiFetch<{ updateId?: number; messageId?: number; chatId?: string }>(
`/bots/by-username/${encodeURIComponent(botRef)}/messages`, `/bots/by-username/${encodeURIComponent(botRef)}/messages`,
{ method: 'POST', body: JSON.stringify({ text }) }, { method: 'POST', body: JSON.stringify({ text, roomId }) },
token token
); );
} }

View File

@@ -914,7 +914,9 @@ export class AuthGrpcController {
inviteeUserId: command.inviteeUserId inviteeUserId: command.inviteeUserId
}); });
if (invite.status === 'ACCEPTED') { if (invite.status === 'ACCEPTED') {
await this.chat.syncFamilyChats(command.groupId); if (invite.inviteeUserId) {
await this.chat.ensureBotRoomForMember(command.groupId, command.requesterId, invite.inviteeUserId);
}
} }
return invite; return invite;
} }
@@ -1192,8 +1194,8 @@ export class AuthGrpcController {
} }
@GrpcMethod('BotService', 'SubmitBotInboundMessage') @GrpcMethod('BotService', 'SubmitBotInboundMessage')
submitBotInboundMessage(command: { senderUserId: string; botRef: string; text: string }) { submitBotInboundMessage(command: { senderUserId: string; botRef: string; text: string; roomId?: string }) {
return this.botInbound.submitUserMessage(command.senderUserId, command.botRef, command.text); return this.botInbound.submitUserMessage(command.senderUserId, command.botRef, command.text, command.roomId);
} }
@GrpcMethod('BotService', 'SubmitBotCallbackQuery') @GrpcMethod('BotService', 'SubmitBotCallbackQuery')
@@ -1202,8 +1204,8 @@ export class AuthGrpcController {
} }
@GrpcMethod('BotService', 'ListBotChatMessages') @GrpcMethod('BotService', 'ListBotChatMessages')
listBotChatMessages(command: { userId: string; botRef: string }) { listBotChatMessages(command: { userId: string; botRef: string; roomId?: string }) {
return this.botApi.listBotChatMessages(command.userId, command.botRef); return this.botApi.listBotChatMessages(command.userId, command.botRef, command.roomId);
} }
private toSessionResponse(session: { id: string; userId: string; deviceId: string | null; pinVerified: boolean; status: string; ipAddress: string | null; userAgent: string | null; expiresAt: Date; createdAt: Date; updatedAt: Date }) { private toSessionResponse(session: { id: string; userId: string; deviceId: string | null; pinVerified: boolean; status: string; ipAddress: string | null; userAgent: string | null; expiresAt: Date; createdAt: Date; updatedAt: Date }) {

View File

@@ -208,7 +208,7 @@ export class BotApiService {
return message; return message;
} }
async listBotChatMessages(userId: string, botRef: string) { async listBotChatMessages(userId: string, botRef: string, roomId?: string) {
const sender = await this.prisma.user.findUnique({ const sender = await this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { isSystemAccount: true, linkedBotId: true, deletedAt: true, status: true } select: { isSystemAccount: true, linkedBotId: true, deletedAt: true, status: true }
@@ -247,7 +247,9 @@ export class BotApiService {
}; };
} }
const chat = await this.prisma.botChat.findUnique({ const chat = roomId
? await this.resolveSharedFamilyBotChat(bot.id, userId, roomId)
: await this.prisma.botChat.findUnique({
where: { botId_recipientUserId: { botId: bot.id, recipientUserId: userId } }, where: { botId_recipientUserId: { botId: bot.id, recipientUserId: userId } },
include: { menuButton: true } include: { menuButton: true }
}); });
@@ -336,6 +338,30 @@ export class BotApiService {
}; };
} }
private async resolveSharedFamilyBotChat(botId: string, viewerUserId: string, roomId: string) {
const room = await this.prisma.chatRoom.findUnique({
where: { id: roomId },
include: {
members: { include: { user: { select: { linkedBotId: true } } } }
}
});
if (!room || room.type !== 'BOT' || !room.members.some((member) => member.userId === viewerUserId)) {
return null;
}
const botMember = room.members.find((member) => member.user.linkedBotId === botId);
if (!botMember || room.peerUserId !== botMember.userId) {
return null;
}
const ownerUserId =
room.createdById ??
room.members.find((member) => !member.user.linkedBotId)?.userId ??
viewerUserId;
return this.prisma.botChat.findUnique({
where: { botId_recipientUserId: { botId, recipientUserId: ownerUserId } },
include: { menuButton: true }
});
}
private async setChatMenuButton(bot: ValidatedBot, payload: SetChatMenuButtonPayload) { private async setChatMenuButton(bot: ValidatedBot, payload: SetChatMenuButtonPayload) {
const chatIdRaw = payload.chat_id; const chatIdRaw = payload.chat_id;
const parsedMenuButton = parseMenuButtonInput(payload.menu_button); const parsedMenuButton = parseMenuButtonInput(payload.menu_button);
@@ -865,7 +891,7 @@ export class BotApiService {
mediaUrl?: string mediaUrl?: string
) { ) {
const payloadMarkup = this.serializer.readPayload(message.payload); const payloadMarkup = this.serializer.readPayload(message.payload);
await this.notifications.publishRealtime(userId, 'bot_message', bot.name, message.text ?? '', { const payload = {
botId: bot.id, botId: bot.id,
botUsername: bot.username, botUsername: bot.username,
chatId: chat.telegramChatId.toString(), chatId: chat.telegramChatId.toString(),
@@ -875,7 +901,9 @@ export class BotApiService {
mediaUrl: message.mediaUrl ?? mediaUrl ?? null, mediaUrl: message.mediaUrl ?? mediaUrl ?? null,
replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null, replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null,
telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null
}); };
await this.notifications.publishRealtime(userId, 'bot_message', bot.name, message.text ?? '', payload);
await this.publishToSharedBotRoomMembers(userId, bot, 'bot_message', message.text ?? '', payload);
} }
private async publishBotMessagePinned( private async publishBotMessagePinned(
@@ -888,14 +916,16 @@ export class BotApiService {
pinnedAt?: Date | null; pinnedAt?: Date | null;
} }
) { ) {
await this.notifications.publishRealtime(userId, 'bot_message_pinned', bot.name, '', { const payload = {
botId: bot.id, botId: bot.id,
botUsername: bot.username, botUsername: bot.username,
chatId: chat.telegramChatId.toString(), chatId: chat.telegramChatId.toString(),
messageId: message.telegramMsgId, messageId: message.telegramMsgId,
isPinned: Boolean(message.isPinned), isPinned: Boolean(message.isPinned),
pinnedAt: message.pinnedAt?.toISOString() ?? null pinnedAt: message.pinnedAt?.toISOString() ?? null
}); };
await this.notifications.publishRealtime(userId, 'bot_message_pinned', bot.name, '', payload);
await this.publishToSharedBotRoomMembers(userId, bot, 'bot_message_pinned', '', payload);
} }
private async publishBotMessageEdited( private async publishBotMessageEdited(
@@ -913,7 +943,7 @@ export class BotApiService {
replyMarkup: unknown replyMarkup: unknown
) { ) {
const payloadMarkup = this.serializer.readPayload(message.payload); const payloadMarkup = this.serializer.readPayload(message.payload);
await this.notifications.publishRealtime(userId, 'bot_message_edited', bot.name, message.text ?? '', { const payload = {
botId: bot.id, botId: bot.id,
botUsername: bot.username, botUsername: bot.username,
chatId: chat.telegramChatId.toString(), chatId: chat.telegramChatId.toString(),
@@ -924,7 +954,41 @@ export class BotApiService {
replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null, replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null,
telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null, telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null,
editedAt: message.editedAt?.toISOString() ?? new Date().toISOString() editedAt: message.editedAt?.toISOString() ?? new Date().toISOString()
};
await this.notifications.publishRealtime(userId, 'bot_message_edited', bot.name, message.text ?? '', payload);
await this.publishToSharedBotRoomMembers(userId, bot, 'bot_message_edited', message.text ?? '', payload);
}
private async publishToSharedBotRoomMembers(
ownerUserId: string,
bot: ValidatedBot,
eventType: string,
message: string,
payload: Record<string, unknown>
) {
const rooms = await this.prisma.chatRoom.findMany({
where: {
type: 'BOT',
botUsername: bot.username,
createdById: ownerUserId
},
include: {
members: { include: { user: { select: { linkedBotId: true } } } }
}
}); });
const recipients = new Set<string>();
for (const room of rooms) {
for (const member of room.members) {
if (member.userId !== ownerUserId && !member.user.linkedBotId) {
recipients.add(member.userId);
}
}
}
await Promise.all(
[...recipients].map((recipientId) => this.notifications.publishRealtime(recipientId, eventType, bot.name, message, payload))
);
} }
private mergePayload(existing: unknown, parsed: ReturnType<TelegramSerializerService['parseReplyMarkup']>) { private mergePayload(existing: unknown, parsed: ReturnType<TelegramSerializerService['parseReplyMarkup']>) {

View File

@@ -17,7 +17,7 @@ export class BotInboundService {
private readonly seed: BotFatherSeedService, private readonly seed: BotFatherSeedService,
private readonly assistant: BotFatherAssistantService private readonly assistant: BotFatherAssistantService
) {} ) {}
async submitUserMessage(senderUserId: string, botRef: string, text: string) { async submitUserMessage(senderUserId: string, botRef: string, text: string, roomId?: string) {
await this.assertHumanSender(senderUserId); await this.assertHumanSender(senderUserId);
const trimmedText = text.trim(); const trimmedText = text.trim();
@@ -30,7 +30,9 @@ export class BotInboundService {
throw new NotFoundException('Бот не найден или заблокирован'); throw new NotFoundException('Бот не найден или заблокирован');
} }
const chat = await this.ensureBotChat(bot.id, senderUserId); const chat = roomId
? await this.resolveSharedFamilyBotChat(bot.id, senderUserId, roomId)
: await this.ensureBotChat(bot.id, senderUserId);
const event = await this.createMessageEvent(bot.id, chat.id, senderUserId, chat.telegramChatId, trimmedText); const event = await this.createMessageEvent(bot.id, chat.id, senderUserId, chat.telegramChatId, trimmedText);
await this.dispatchEvent(event); await this.dispatchEvent(event);
if (bot.isSystemBot) { if (bot.isSystemBot) {
@@ -210,4 +212,25 @@ export class BotInboundService {
return fallback; return fallback;
} }
} }
private async resolveSharedFamilyBotChat(botId: string, senderUserId: string, roomId: string) {
const room = await this.prisma.chatRoom.findUnique({
where: { id: roomId },
include: {
members: { include: { user: { select: { linkedBotId: true } } } }
}
});
if (!room || room.type !== 'BOT' || !room.members.some((member) => member.userId === senderUserId)) {
throw new NotFoundException('Чат с ботом не найден');
}
const botMember = room.members.find((member) => member.user.linkedBotId === botId);
if (!botMember || room.peerUserId !== botMember.userId) {
throw new NotFoundException('Бот не найден в этом чате');
}
const ownerUserId =
room.createdById ??
room.members.find((member) => !member.user.linkedBotId)?.userId ??
senderUserId;
return this.ensureBotChat(botId, ownerUserId);
}
} }

View File

@@ -127,8 +127,6 @@ export class ChatService {
}); });
const humans = members.filter((member) => !member.user.linkedBotId); const humans = members.filter((member) => !member.user.linkedBotId);
const bots = members.filter((member) => member.user.linkedBotId);
const legacyRooms = await this.prisma.chatRoom.findMany({ const legacyRooms = await this.prisma.chatRoom.findMany({
where: { groupId, type: 'GROUP' }, where: { groupId, type: 'GROUP' },
include: { members: true } include: { members: true }
@@ -177,18 +175,24 @@ export class ChatService {
} }
} }
for (const botMember of bots) { // BOT-чаты создаются только для тех людей, которым явно выдали доступ к боту.
for (const human of humans) { // Не создаём комнаты "каждый человек x каждый бот", иначе бот становится видимым всей семье.
await this.ensureBotRoom( }
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, groupId,
human.userId, requesterId,
botMember.userId, botUserId,
botMember.user.linkedBot!.username, botMember.user.linkedBot.username,
botMember.user.displayName botMember.user.displayName
); );
} }
}
}
async createRoom(userId: string, groupId: string, name: string, memberUserIds: string[]) { async createRoom(userId: string, groupId: string, name: string, memberUserIds: string[]) {
const group = await this.family.assertFamilyMember(groupId, userId); const group = await this.family.assertFamilyMember(groupId, userId);
@@ -313,7 +317,7 @@ export class ChatService {
if (room.type === 'GENERAL') { if (room.type === 'GENERAL') {
throw new BadRequestException('В общий чат нельзя добавлять участников вручную'); throw new BadRequestException('В общий чат нельзя добавлять участников вручную');
} }
if (room.type === 'DIRECT' || room.type === 'BOT' || room.type === 'E2E') { if (room.type === 'DIRECT' || room.type === 'E2E') {
throw new BadRequestException('Нельзя изменять состав личного чата или чата с ботом'); throw new BadRequestException('Нельзя изменять состав личного чата или чата с ботом');
} }
if (room.members.some((member) => member.userId === memberUserId)) { if (room.members.some((member) => member.userId === memberUserId)) {
@@ -322,6 +326,15 @@ export class ChatService {
if (!room.group.members.some((member) => member.userId === memberUserId)) { if (!room.group.members.some((member) => member.userId === memberUserId)) {
throw new BadRequestException('Пользователь должен состоять в семье'); 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({ await this.prisma.chatRoomMember.create({
data: { roomId, userId: memberUserId } data: { roomId, userId: memberUserId }
@@ -335,21 +348,24 @@ export class ChatService {
if (room.type === 'GENERAL') { if (room.type === 'GENERAL') {
throw new BadRequestException('Нельзя удалить участника из общего чата'); throw new BadRequestException('Нельзя удалить участника из общего чата');
} }
if (room.type === 'DIRECT' || room.type === 'BOT' || room.type === 'E2E') { if (room.type === 'DIRECT' || room.type === 'E2E') {
throw new BadRequestException('Нельзя изменять состав личного чата или чата с ботом'); throw new BadRequestException('Нельзя изменять состав личного чата или чата с ботом');
} }
const isFamilyOwner = room.group.ownerId === userId; const isFamilyOwner = room.group.ownerId === userId;
const isChatCreator = room.createdById === userId; const isChatCreator = room.createdById === userId;
if (!isFamilyOwner && !isChatCreator) { if (room.type !== 'BOT' && !isFamilyOwner && !isChatCreator) {
throw new ForbiddenException('Недостаточно прав для удаления участника из чата'); throw new ForbiddenException('Недостаточно прав для удаления участника из чата');
} }
if (room.type === 'BOT' && memberUserId === room.peerUserId) {
throw new BadRequestException('Нельзя удалить бота из его чата');
}
if (memberUserId === room.createdById && !isFamilyOwner) { if (memberUserId === room.createdById && !isFamilyOwner) {
throw new BadRequestException('Создателя чата может удалить только создатель семьи'); throw new BadRequestException('Создателя чата может удалить только создатель семьи');
} }
if (memberUserId === userId && room.members.length <= 1) { if (memberUserId === userId && room.members.length <= (room.type === 'BOT' ? 2 : 1)) {
throw new BadRequestException('В чате должен остаться хотя бы один участник'); throw new BadRequestException('В чате должен остаться хотя бы один участник');
} }
@@ -915,7 +931,7 @@ export class ChatService {
userId: string; userId: string;
notificationsMuted: boolean; notificationsMuted: boolean;
pinnedAt?: Date | null; pinnedAt?: Date | null;
user: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null }; user: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null; linkedBotId?: string | null };
}>; }>;
}, },
familyRoleByUserId: Map<string, string>, familyRoleByUserId: Map<string, string>,
@@ -924,7 +940,9 @@ export class ChatService {
) { ) {
const viewerMembership = viewerId ? room.members.find((member) => member.userId === viewerId) : undefined; const viewerMembership = viewerId ? room.members.find((member) => member.userId === viewerId) : undefined;
const peerMember = const peerMember =
viewerId && (room.type === 'DIRECT' || room.type === 'BOT' || room.type === 'E2E') 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) ? room.members.find((member) => member.userId !== viewerId)
: undefined; : undefined;
@@ -1195,6 +1213,7 @@ export class ChatService {
name: botDisplayName, name: botDisplayName,
peerUserId: botUserId, peerUserId: botUserId,
botUsername, botUsername,
createdById: humanUserId,
isE2E: false, isE2E: false,
members: { members: {
create: [{ userId: humanUserId }, { userId: botUserId }] create: [{ userId: humanUserId }, { userId: botUserId }]

View File

@@ -106,7 +106,7 @@ export class FamilyService {
return this.toGroup(group); return this.toGroup(group, ownerId);
} }
@@ -124,7 +124,7 @@ export class FamilyService {
}); });
return { groups: groups.map((group) => this.toGroup(group)) }; return { groups: await Promise.all(groups.map((group) => this.toGroup(group, userId))) };
} }
@@ -136,7 +136,7 @@ export class FamilyService {
this.assertMember(group, requesterId); this.assertMember(group, requesterId);
return this.toGroup(group); return this.toGroup(group, requesterId);
} }
@@ -166,7 +166,7 @@ export class FamilyService {
}); });
return this.toGroup(updated); return this.toGroup(updated, requesterId);
} }
@@ -457,23 +457,30 @@ export class FamilyService {
throw new BadRequestException('Пользователь не найден. Выберите его из результатов поиска.'); throw new BadRequestException('Пользователь не найден. Выберите его из результатов поиска.');
} }
const inviter = await this.prisma.user.findUnique({ where: { id: requesterId } });
const isBotInvite = isProtectedSystemAccount(invitee);
const inviteeAlreadyInFamily = group.members.some((member) => member.userId === invitee.id);
if (invitee.id === requesterId) { if (invitee.id === requesterId) {
throw new BadRequestException('Нельзя пригласить самого себя'); throw new BadRequestException('Нельзя пригласить самого себя');
} }
if (group.members.some((member) => member.userId === invitee.id)) { if (isBotInvite) {
const visibleBotUserIds = await this.getVisibleBotUserIds(groupId, requesterId);
if (visibleBotUserIds.has(invitee.id)) {
throw new BadRequestException('Этот бот уже доступен вам в семье');
}
} else if (inviteeAlreadyInFamily) {
throw new BadRequestException('Пользователь уже состоит в семье'); throw new BadRequestException('Пользователь уже состоит в семье');
} }
const existingPending = await this.prisma.familyInvite.findFirst({ const existingPending = await this.prisma.familyInvite.findFirst({
where: { groupId, inviteeUserId: invitee.id, status: 'PENDING' } where: { groupId, inviteeUserId: invitee.id, status: 'PENDING' }
}); });
if (existingPending) { if (existingPending && !isBotInvite) {
throw new BadRequestException('Приглашение уже отправлено'); throw new BadRequestException('Приглашение уже отправлено');
} }
const inviter = await this.prisma.user.findUnique({ where: { id: requesterId } });
const isBotInvite = isProtectedSystemAccount(invitee);
if (isBotInvite) { if (isBotInvite) {
const invite = await this.prisma.familyInvite.create({ const invite = await this.prisma.familyInvite.create({
data: { data: {
@@ -490,7 +497,9 @@ export class FamilyService {
inviter: true inviter: true
} }
}); });
if (!inviteeAlreadyInFamily) {
await this.addMember(groupId, invitee.id, 'bot'); await this.addMember(groupId, invitee.id, 'bot');
}
await this.notifications.create( await this.notifications.create(
requesterId, requesterId,
'family_bot_added', 'family_bot_added',
@@ -537,24 +546,18 @@ export class FamilyService {
return { users: [] }; return { users: [] };
} }
const excludedIds = [...group.members.map((member) => member.userId), requesterId]; const excludedIds = [...group.members.filter((member) => !member.user?.linkedBotId).map((member) => member.userId), requesterId];
const excludedBotUsernames = new Set( const visibleBotUserIds = await this.getVisibleBotUserIds(groupId, requesterId);
group.members
.map((member) => member.user?.linkedBot?.username)
.filter((username): username is string => Boolean(username))
);
const botFatherUserId = await this.botFatherSeed.getBotFatherUserId(); const botFatherUserId = await this.botFatherSeed.getBotFatherUserId();
const botFatherBotId = await this.botFatherSeed.getBotFatherBotId(); const botFatherBotId = await this.botFatherSeed.getBotFatherBotId();
const getMyIdUserId = await this.botFatherSeed.getGetMyIdBotUserId(); const getMyIdUserId = await this.botFatherSeed.getGetMyIdBotUserId();
const getMyIdBotId = await this.botFatherSeed.getGetMyIdBotId(); const getMyIdBotId = await this.botFatherSeed.getGetMyIdBotId();
const botFatherMatches = const botFatherMatches =
/bot\s*father|бот\s*father|botfather/i.test(trimmed) && /bot\s*father|бот\s*father|botfather/i.test(trimmed) &&
!excludedIds.includes(botFatherUserId) && !visibleBotUserIds.has(botFatherUserId);
!excludedBotUsernames.has(BOTFATHER_BOT_USERNAME);
const getMyIdMatches = const getMyIdMatches =
/get\s*my\s*id|getmyid|мой\s*id|айди|chat[_\s-]*id/i.test(trimmed) && /get\s*my\s*id|getmyid|мой\s*id|айди|chat[_\s-]*id/i.test(trimmed) &&
!excludedIds.includes(getMyIdUserId) && !visibleBotUserIds.has(getMyIdUserId);
!excludedBotUsernames.has(GET_MY_ID_BOT_USERNAME);
const digits = trimmed.replace(/\D/g, ''); const digits = trimmed.replace(/\D/g, '');
const normalizedBotQuery = normalizeBotUsername(trimmed.replace(/^@/, '')); const normalizedBotQuery = normalizeBotUsername(trimmed.replace(/^@/, ''));
@@ -619,13 +622,12 @@ export class FamilyService {
}> = []; }> = [];
for (const bot of matchedBots) { for (const bot of matchedBots) {
if (excludedBotUsernames.has(bot.username)) continue;
if (botFatherMatches && bot.username === BOTFATHER_BOT_USERNAME) continue; if (botFatherMatches && bot.username === BOTFATHER_BOT_USERNAME) continue;
if (getMyIdMatches && bot.username === GET_MY_ID_BOT_USERNAME) continue; if (getMyIdMatches && bot.username === GET_MY_ID_BOT_USERNAME) continue;
const { userId } = bot.linkedSystemUser const { userId } = bot.linkedSystemUser
? { userId: bot.linkedSystemUser.id } ? { userId: bot.linkedSystemUser.id }
: await this.botFather.ensureBotSystemUser(bot.id); : await this.botFather.ensureBotSystemUser(bot.id);
if (excludedIds.includes(userId)) continue; if (visibleBotUserIds.has(userId)) continue;
botCandidates.push({ botCandidates.push({
id: userId, id: userId,
botId: bot.id, botId: bot.id,
@@ -963,7 +965,7 @@ export class FamilyService {
private toGroup(group: { private async toGroup(group: {
id: string; id: string;
@@ -1000,7 +1002,9 @@ export class FamilyService {
}>; }>;
}) { }, viewerId?: string) {
const visibleBotUserIds = viewerId ? await this.getVisibleBotUserIds(group.id, viewerId) : new Set<string>();
const visibleMembers = group.members.filter((member) => !member.user?.linkedBotId || visibleBotUserIds.has(member.userId));
return { return {
@@ -1016,13 +1020,13 @@ export class FamilyService {
updatedAt: group.updatedAt.toISOString(), updatedAt: group.updatedAt.toISOString(),
members: group.members.map((member) => this.toMember(member)), members: visibleMembers.map((member) => this.toMember(member)),
botFatherAvailable: !group.members.some( botFatherAvailable: !visibleMembers.some(
(member) => member.user?.linkedBot?.username === BOTFATHER_BOT_USERNAME (member) => member.user?.linkedBot?.username === BOTFATHER_BOT_USERNAME
), ),
getMyIdBotAvailable: !group.members.some( getMyIdBotAvailable: !visibleMembers.some(
(member) => member.user?.linkedBot?.username === GET_MY_ID_BOT_USERNAME (member) => member.user?.linkedBot?.username === GET_MY_ID_BOT_USERNAME
) )
@@ -1030,6 +1034,27 @@ export class FamilyService {
} }
private async getVisibleBotUserIds(groupId: string, viewerId: string) {
const rooms = await this.prisma.chatRoom.findMany({
where: {
groupId,
type: 'BOT',
members: { some: { userId: viewerId } }
},
include: {
members: {
include: { user: { select: { linkedBotId: true } } }
}
}
});
return new Set(
rooms
.flatMap((room) => room.members)
.filter((member) => Boolean(member.user.linkedBotId))
.map((member) => member.userId)
);
}
private toMember(member: { private toMember(member: {

View File

@@ -186,6 +186,7 @@ message SubmitBotInboundMessageRequest {
string senderUserId = 1; string senderUserId = 1;
string botRef = 2; string botRef = 2;
string text = 3; string text = 3;
optional string roomId = 4;
} }
message SubmitBotInboundMessageResponse { message SubmitBotInboundMessageResponse {
@@ -210,6 +211,7 @@ message SubmitBotCallbackQueryResponse {
message ListBotChatMessagesRequest { message ListBotChatMessagesRequest {
string userId = 1; string userId = 1;
string botRef = 2; string botRef = 2;
optional string roomId = 3;
} }
message BotChatMessageItem { message BotChatMessageItem {