From 4e853f8041d7bd3a0a5064eefd863aec92b04122 Mon Sep 17 00:00:00 2001 From: lendry Date: Fri, 26 Jun 2026 17:49:22 +0300 Subject: [PATCH] update --- .../components/chat/chat-image-lightbox.tsx | 148 +++++++ .../components/chat/chat-media-album-grid.tsx | 84 ++++ .../chat/chat-media-compose-dialog.tsx | 366 ++++++++++++++++++ .../components/family/family-bot-chat.tsx | 31 +- .../components/family/family-group-view.tsx | 222 ++++++++++- .../components/family/mini-family-chat.tsx | 171 +++++--- apps/frontend/lib/api.ts | 1 + apps/frontend/lib/chat-image-compress.ts | 114 ++++++ apps/frontend/lib/chat-media-album.ts | 75 ++++ apps/frontend/lib/chat-media-upload.ts | 121 ++++++ apps/sso-core/prisma/schema.prisma | 5 + .../src/domain/bot/bot-api.service.ts | 237 +++++++++--- .../bot/bot-father-assistant.service.ts | 25 +- .../src/domain/bot/bot-inbound.service.ts | 12 +- apps/sso-core/src/domain/chat.service.ts | 65 +++- shared/proto/bot.proto | 1 + tauri_app/docker/build-android-apk.sh | 36 +- 17 files changed, 1547 insertions(+), 167 deletions(-) create mode 100644 apps/frontend/components/chat/chat-image-lightbox.tsx create mode 100644 apps/frontend/components/chat/chat-media-album-grid.tsx create mode 100644 apps/frontend/components/chat/chat-media-compose-dialog.tsx create mode 100644 apps/frontend/lib/chat-image-compress.ts create mode 100644 apps/frontend/lib/chat-media-album.ts create mode 100644 apps/frontend/lib/chat-media-upload.ts diff --git a/apps/frontend/components/chat/chat-image-lightbox.tsx b/apps/frontend/components/chat/chat-image-lightbox.tsx new file mode 100644 index 0000000..80ed37a --- /dev/null +++ b/apps/frontend/components/chat/chat-image-lightbox.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { useEffect } from 'react'; +import { ChevronLeft, ChevronRight, Download, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import type { ChatMessage } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +interface ChatImageLightboxProps { + open: boolean; + images: ChatMessage[]; + index: number; + mediaUrls: Record; + onClose: () => void; + onIndexChange: (index: number) => void; + onDownload?: (message: ChatMessage) => void; +} + +export function ChatImageLightbox({ + open, + images, + index, + mediaUrls, + onClose, + onIndexChange, + onDownload +}: ChatImageLightboxProps) { + const current = images[index]; + + useEffect(() => { + if (!open) return; + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') onClose(); + if (event.key === 'ArrowLeft') onIndexChange(Math.max(0, index - 1)); + if (event.key === 'ArrowRight') onIndexChange(Math.min(images.length - 1, index + 1)); + } + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [open, index, images.length, onClose, onIndexChange]); + + if (!open || !current?.storageKey) return null; + + const media = mediaUrls[current.storageKey]; + + return ( +
+
+
+

{current.senderName}

+

+ {index + 1} из {images.length} +

+
+
+ {onDownload ? ( + + ) : null} + +
+
+ +
+ + +
+ {media?.blobUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
Загрузка изображения...
+ )} +
+ + +
+ + {current.content ? ( +
{current.content}
+ ) : null} + + {images.length > 1 ? ( +
+ {images.map((image, imageIndex) => { + const thumb = image.storageKey ? mediaUrls[image.storageKey] : undefined; + return ( + + ); + })} +
+ ) : null} +
+ ); +} diff --git a/apps/frontend/components/chat/chat-media-album-grid.tsx b/apps/frontend/components/chat/chat-media-album-grid.tsx new file mode 100644 index 0000000..f9fc24c --- /dev/null +++ b/apps/frontend/components/chat/chat-media-album-grid.tsx @@ -0,0 +1,84 @@ +'use client'; + +import type { ChatMessage } from '@/lib/api'; +import { parseChatMediaMetadata } from '@/lib/chat-media-album'; +import { cn } from '@/lib/utils'; + +interface ChatMediaAlbumGridProps { + messages: ChatMessage[]; + mediaUrls: Record; + className?: string; + onImageClick?: (message: ChatMessage, index: number) => void; +} + +function tileClass(count: number, index: number) { + if (count === 1) return 'col-span-2 row-span-2'; + if (count === 2) return 'col-span-1 row-span-2'; + if (count === 3) { + if (index === 0) return 'col-span-1 row-span-2'; + return 'col-span-1 row-span-1'; + } + if (count === 4) return 'col-span-1 row-span-1'; + if (count === 5) { + if (index === 0) return 'col-span-2 row-span-2'; + return 'col-span-1 row-span-1'; + } + if (count >= 6) { + if (index === 0) return 'col-span-2 row-span-2'; + return 'col-span-1 row-span-1'; + } + return 'col-span-1 row-span-1'; +} + +export function ChatMediaAlbumGrid({ messages, mediaUrls, className, onImageClick }: ChatMediaAlbumGridProps) { + const imageMessages = messages.filter((message) => message.type === 'IMAGE' && message.storageKey); + const fileMessages = messages.filter((message) => message.type === 'FILE' && message.storageKey); + const count = imageMessages.length; + + if (!count && fileMessages.length) { + return ( +
+ {fileMessages.map((message) => ( +
+ {message.content || parseChatMediaMetadata(message.metadataJson).fileName || 'Файл'} +
+ ))} +
+ ); + } + + return ( +
+ {imageMessages.map((message, index) => { + const media = message.storageKey ? mediaUrls[message.storageKey] : undefined; + const hiddenCount = count > 6 && index === 5 ? count - 6 : 0; + return ( + + ); + })} +
+ ); +} diff --git a/apps/frontend/components/chat/chat-media-compose-dialog.tsx b/apps/frontend/components/chat/chat-media-compose-dialog.tsx new file mode 100644 index 0000000..44fb7b8 --- /dev/null +++ b/apps/frontend/components/chat/chat-media-compose-dialog.tsx @@ -0,0 +1,366 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Loader2, Plus, Smile, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { createFilePreviewUrl, isCompressibleImageFile, readImageDimensions } from '@/lib/chat-image-compress'; +import { formatFileSize } from '@/lib/chat-media'; +import { cn } from '@/lib/utils'; + +export interface ChatMediaComposeItem { + id: string; + file: File; + previewUrl: string | null; + sendAsFile: boolean; + width?: number; + height?: number; +} + +const MAX_FILES = 10; + +function createComposeItem(file: File, sendAsFile = false): ChatMediaComposeItem { + return { + id: crypto.randomUUID(), + file, + previewUrl: createFilePreviewUrl(file), + sendAsFile + }; +} + +export function useChatMediaComposer() { + const [open, setOpen] = useState(false); + const [items, setItems] = useState([]); + const [caption, setCaption] = useState(''); + const [sendAsFile, setSendAsFile] = useState(false); + + const reset = useCallback(() => { + setItems((current) => { + current.forEach((item) => { + if (item.previewUrl) URL.revokeObjectURL(item.previewUrl); + }); + return []; + }); + setCaption(''); + setSendAsFile(false); + }, []); + + const close = useCallback(() => { + setOpen(false); + reset(); + }, [reset]); + + const openWithFiles = useCallback(async (files: File[], options?: { sendAsFile?: boolean }) => { + const limited = files.slice(0, MAX_FILES); + const initialSendAsFile = options?.sendAsFile ?? false; + const nextItems = await Promise.all( + limited.map(async (file) => { + const item = createComposeItem(file, initialSendAsFile); + if (isCompressibleImageFile(file)) { + const dimensions = await readImageDimensions(file); + item.width = dimensions.width; + item.height = dimensions.height; + } + return item; + }) + ); + setItems(nextItems); + setSendAsFile(initialSendAsFile); + setCaption(''); + setOpen(true); + }, []); + + const addFiles = useCallback(async (files: File[]) => { + const limited = files.slice(0, MAX_FILES); + if (!limited.length) return; + + const appended = await Promise.all( + limited.map(async (file) => { + const item = createComposeItem(file, sendAsFile); + if (isCompressibleImageFile(file)) { + const dimensions = await readImageDimensions(file); + item.width = dimensions.width; + item.height = dimensions.height; + } + return item; + }) + ); + + setItems((current) => [...current, ...appended].slice(0, MAX_FILES)); + }, [sendAsFile]); + + const removeItem = useCallback((id: string) => { + setItems((current) => { + const target = current.find((item) => item.id === id); + if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl); + const next = current.filter((item) => item.id !== id); + if (!next.length) { + setOpen(false); + setCaption(''); + setSendAsFile(false); + } + return next; + }); + }, []); + + useEffect(() => { + setItems((current) => current.map((item) => ({ ...item, sendAsFile }))); + }, [sendAsFile]); + + const hasImages = useMemo(() => items.some((item) => isCompressibleImageFile(item.file)), [items]); + + return { + open, + items, + caption, + sendAsFile, + hasImages, + maxFiles: MAX_FILES, + setCaption, + setSendAsFile, + openWithFiles, + addFiles, + removeItem, + close + }; +} + +interface ChatMediaComposeDialogProps { + open: boolean; + items: ChatMediaComposeItem[]; + caption: string; + sendAsFile: boolean; + hasImages: boolean; + maxFiles: number; + sending?: boolean; + onCaptionChange: (value: string) => void; + onSendAsFileChange: (value: boolean) => void; + onRemoveItem: (id: string) => void; + onAddFiles: (files: File[]) => void; + onClose: () => void; + onSend: () => void; +} + +export function ChatMediaComposeDialog({ + open, + items, + caption, + sendAsFile, + hasImages, + maxFiles, + sending = false, + onCaptionChange, + onSendAsFileChange, + onRemoveItem, + onAddFiles, + onClose, + onSend +}: ChatMediaComposeDialogProps) { + const addInputRef = useRef(null); + const activePreview = items[0] ?? null; + + return ( + !next && onClose()}> + +
+ + {items.length > 1 ? `Отправить ${items.length} файла` : 'Отправить изображение'} + +
+ +
+ {activePreview ? ( +
+ {activePreview.previewUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {activePreview.file.name} + ) : ( +
+

{activePreview.file.name}

+

{formatFileSize(activePreview.file.size)}

+
+ )} + +
+ ) : null} + + {items.length > 1 ? ( +
+ {items.map((item) => ( + + ))} +
+ ) : activePreview?.previewUrl ? ( +

Для редактирования нажмите на изображение

+ ) : null} + + {hasImages ? ( + + ) : null} + +
+ onCaptionChange(event.target.value)} + placeholder="Подпись" + className="rounded-xl pr-10" + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + onSend(); + } + }} + /> + +
+
+ +
+ +
+ + +
+
+ + { + const files = Array.from(event.target.files ?? []); + if (files.length) onAddFiles(files); + event.target.value = ''; + }} + /> +
+
+ ); +} + +interface ChatMediaDropOverlayProps { + visible: boolean; + onDropCompressed: (files: File[]) => void; + onDropAsFile: (files: File[]) => void; +} + +export function ChatMediaDropOverlay({ visible, onDropCompressed, onDropAsFile }: ChatMediaDropOverlayProps) { + if (!visible) return null; + + return ( +
+ + +
+ ); +} + +function DropZone({ + title, + subtitle, + className, + onDrop +}: { + title: string; + subtitle: string; + className?: string; + onDrop: (files: File[]) => void; +}) { + return ( +
{ + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + }} + onDrop={(event) => { + event.preventDefault(); + event.stopPropagation(); + const files = Array.from(event.dataTransfer.files ?? []); + if (files.length) onDrop(files); + }} + > +

{title}

+

{subtitle}

+
+ ); +} + +export function extractFilesFromDataTransfer(dataTransfer: DataTransfer | null) { + if (!dataTransfer) return [] as File[]; + return Array.from(dataTransfer.files ?? []); +} + +export function extractFilesFromClipboard(dataTransfer: DataTransfer | null) { + if (!dataTransfer) return [] as File[]; + const files: File[] = []; + for (const item of Array.from(dataTransfer.items ?? [])) { + if (item.kind !== 'file') continue; + const file = item.getAsFile(); + if (file) files.push(file); + } + if (files.length) return files; + return Array.from(dataTransfer.files ?? []); +} diff --git a/apps/frontend/components/family/family-bot-chat.tsx b/apps/frontend/components/family/family-bot-chat.tsx index c52b08f..45ab500 100644 --- a/apps/frontend/components/family/family-bot-chat.tsx +++ b/apps/frontend/components/family/family-bot-chat.tsx @@ -135,6 +135,16 @@ export function FamilyBotChat({ return; } + const eventScope = payload.scope === 'broadcast' ? 'broadcast' : 'private'; + const eventRoomId = typeof payload.roomId === 'string' ? payload.roomId : null; + if (eventScope === 'broadcast') { + if (roomId && eventRoomId && eventRoomId !== roomId) { + return; + } + } else if (roomId) { + // Личные сообщения бота не транслируются другим участникам комнаты. + } + if (event.type === 'bot_message') { const messageId = Number(payload.messageId); if (!Number.isFinite(messageId)) { @@ -144,8 +154,9 @@ export function FamilyBotChat({ const replyMarkupJson = markupJsonFromPayload(payload); const outbound: BotChatMessage = { - id: `out-ws-${messageId}`, + id: `${eventScope === 'broadcast' ? 'broadcast' : 'out'}-ws-${messageId}`, direction: 'out', + scope: eventScope, text: typeof payload.text === 'string' ? payload.text : '', messageType: typeof payload.messageType === 'string' ? payload.messageType : 'text', messageId, @@ -156,7 +167,9 @@ export function FamilyBotChat({ }; setMessages((current) => { - const withoutDuplicate = current.filter((item) => item.messageId !== messageId || item.direction !== 'out'); + const withoutDuplicate = current.filter( + (item) => item.messageId !== messageId || item.direction !== 'out' || item.scope !== eventScope + ); return [...withoutDuplicate, outbound].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); }); return; @@ -191,7 +204,7 @@ export function FamilyBotChat({ const replyMarkupJson = markupJsonFromPayload(payload); setMessages((current) => current.map((item) => - item.messageId === messageId && item.direction === 'out' + item.messageId === messageId && item.direction === 'out' && (item.scope ?? 'private') === eventScope ? { ...item, text: typeof payload.text === 'string' ? payload.text : item.text, @@ -212,7 +225,7 @@ export function FamilyBotChat({ setMessages((current) => current.map((item) => - item.messageId === messageId && item.direction === 'out' + item.messageId === messageId && item.direction === 'out' && (item.scope ?? 'private') === eventScope ? { ...item, isPinned: Boolean(payload.isPinned), @@ -223,7 +236,7 @@ export function FamilyBotChat({ ); } }); - }, [botRef, loadMessages, subscribe]); + }, [botRef, loadMessages, roomId, subscribe]); async function handleSend() { if (!token || !draft.trim() || sending) return; @@ -271,12 +284,18 @@ export function FamilyBotChat({ ) : messages.length ? ( messages.map((message) => { const mine = message.direction === 'in'; + const isBroadcast = message.scope === 'broadcast'; const hasKeyboard = Boolean(message.replyMarkupJson?.trim()); return (
showToast('Текст скопирован')}>
- {!mine ?

{botName}

: null} + {!mine ? ( +

+ {botName} + {isBroadcast ? ' · уведомление' : ''} +

+ ) : null}

{message.text || '…'}

{!mine && hasKeyboard && message.messageId > 0 ? ( (null); const [chatToolsOpen, setChatToolsOpen] = useState(false); const [chatToolsTab, setChatToolsTab] = useState('search'); + const [mediaDropVisible, setMediaDropVisible] = useState(false); + const [lightboxIndex, setLightboxIndex] = useState(null); + + const mediaComposer = useChatMediaComposer(); const messagesEndRef = useRef(null); const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 }); @@ -268,6 +308,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { const recordingStartedAtRef = useRef(null); const mediaUrlsRef = useRef>({}); const fileInputRef = useRef(null); + const dragDepthRef = useRef(0); useEffect(() => { mediaUrlsRef.current = mediaUrls; @@ -279,6 +320,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { }, []); const visibleMessages = useMemo(() => messages.filter((message) => !message.isDeleted), [messages]); + const chatImageMessages = useMemo(() => collectChatImageMessages(visibleMessages), [visibleMessages]); const messageById = useMemo( () => new Map(visibleMessages.map((message) => [message.id, message])), [visibleMessages] @@ -1127,6 +1169,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { async function uploadChatFile(file: File, options?: { voice?: boolean; durationMs?: number }) { if (!token || !activeRoomId || !activeRoom) return; + if (!options?.voice) { + queueMediaFiles([file]); + return; + } setSending(true); try { const contentType = resolveChatContentType(file); @@ -1191,6 +1237,68 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { } } + const queueMediaFiles = useCallback( + (files: File[], options?: { sendAsFile?: boolean }) => { + if (!activeRoom || activeRoomIsBot || !files.length) return; + if (files.length > mediaComposer.maxFiles) { + showToast(`Можно отправить не более ${mediaComposer.maxFiles} файлов за раз`); + } + const limited = files.slice(0, mediaComposer.maxFiles); + if (mediaComposer.open && mediaComposer.items.length) { + void mediaComposer.addFiles(limited); + return; + } + void mediaComposer.openWithFiles(limited, options); + }, + [activeRoom, activeRoomIsBot, mediaComposer] + ); + + async function submitMediaCompose() { + if (!token || !activeRoomId || !activeRoom || !mediaComposer.items.length) return; + setSending(true); + try { + const uploaded = await uploadChatMediaBatch({ + roomId: activeRoomId, + token, + items: mediaComposer.items.map((item) => ({ + file: item.file, + sendAsFile: item.sendAsFile, + width: item.width, + height: item.height + })), + caption: mediaComposer.caption, + isE2E: activeRoomIsE2E, + userId: user?.id, + peerE2eKey, + encryptOutgoing + }); + uploaded.forEach((message) => appendMessage(message)); + mediaComposer.close(); + void loadGroup(); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось отправить файлы') ?? 'Ошибка'); + } finally { + setSending(false); + } + } + + useEffect(() => { + if (!activeRoom || activeRoomIsBot) return; + function handlePaste(event: ClipboardEvent) { + const files = extractFilesFromClipboard(event.clipboardData); + if (!files.length) return; + event.preventDefault(); + queueMediaFiles(files, { sendAsFile: false }); + } + window.addEventListener('paste', handlePaste); + return () => window.removeEventListener('paste', handlePaste); + }, [activeRoom, activeRoomIsBot, queueMediaFiles]); + + function openImageLightbox(message: ChatMessage) { + const index = chatImageMessages.findIndex((item) => item.id === message.id); + if (index >= 0) setLightboxIndex(index); + } + async function submitPoll() { if (!token || !activeRoomId) return; const options = pollOptions.map((item) => item.trim()).filter(Boolean); @@ -1526,7 +1634,46 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { }} /> ) : ( - <> +
{ + if (activeRoomIsBot) return; + event.preventDefault(); + dragDepthRef.current += 1; + setMediaDropVisible(true); + }} + onDragLeave={(event) => { + if (activeRoomIsBot) return; + event.preventDefault(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setMediaDropVisible(false); + }} + onDragOver={(event) => { + if (activeRoomIsBot) return; + event.preventDefault(); + }} + onDrop={(event) => { + if (activeRoomIsBot) return; + event.preventDefault(); + dragDepthRef.current = 0; + setMediaDropVisible(false); + const files = extractFilesFromDataTransfer(event.dataTransfer); + if (files.length) queueMediaFiles(files, { sendAsFile: false }); + }} + > + { + dragDepthRef.current = 0; + setMediaDropVisible(false); + queueMediaFiles(files, { sendAsFile: true }); + }} + onDropCompressed={(files) => { + dragDepthRef.current = 0; + setMediaDropVisible(false); + queueMediaFiles(files, { sendAsFile: false }); + }} + /> {pinnedMessage ? ( {visibleMessages.map((message, messageIndex) => { + if (shouldSkipGroupedAlbumMessage(visibleMessages, messageIndex)) { + return null; + } + const previousMessage = visibleMessages[messageIndex - 1]; const showDateSeparator = shouldShowChatDateSeparator(message.createdAt, previousMessage?.createdAt); @@ -1591,6 +1742,8 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { const largeEmojiNative = isLargeEmoji ? getSingleEmojiNative({ type: message.type, content: emojiContent ?? message.content, visibleText }) : ''; + const albumMessages = collectAlbumMessages(visibleMessages, messageIndex); + const isAlbumGroup = albumMessages.length > 1 && Boolean(parseChatMediaMetadata(message.metadataJson).albumId); const bubble = (
) : message.storageKey ? ( (() => { + if (isAlbumGroup) { + const captionMessage = albumMessages.find((item) => item.content?.trim()); + return ( +
+ openImageLightbox(item)} + /> + {captionMessage?.content ? ( +

{captionMessage.content}

+ ) : null} +
+ ); + } + const media = mediaUrls[message.storageKey]; const meta = parseMessageMetadata(message.metadataJson); const fileName = e2ePayload?.fileName || meta.fileName || message.content || 'Файл'; @@ -1655,8 +1824,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { } if (message.type === 'IMAGE') { return ( - // eslint-disable-next-line @next/next/no-img-element - {fileName} + ); } if (message.type === 'VOICE' || message.type === 'AUDIO') { @@ -1933,7 +2104,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
)} - +
)} ) : ( @@ -1941,11 +2112,44 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { )} - { - const file = event.target.files?.[0]; - if (file) void uploadChatFile(file); - event.target.value = ''; - }} /> + { + const files = Array.from(event.target.files ?? []); + if (files.length) queueMediaFiles(files); + event.target.value = ''; + }} + /> + + void mediaComposer.addFiles(files)} + onClose={mediaComposer.close} + onSend={() => void submitMediaCompose()} + /> + + setLightboxIndex(null)} + onIndexChange={setLightboxIndex} + onDownload={(message) => void downloadChatFile(message)} + /> ('search'); + const [mediaDropVisible, setMediaDropVisible] = useState(false); + const mediaComposer = useChatMediaComposer(); const messagesEndRef = useRef(null); const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 }); const fileInputRef = useRef(null); + const dragDepthRef = useRef(0); const previousGroupIdRef = useRef(null); const visibleMessages = useMemo( @@ -580,65 +591,66 @@ export function MiniFamilyChat() { } async function uploadChatFile(file: File) { - if (!token || !activeRoom || sending) return; + queueMediaFiles([file]); + } + + const queueMediaFiles = useCallback( + (files: File[], options?: { sendAsFile?: boolean }) => { + if (!activeRoom || view !== 'chat' || !files.length) return; + if (files.length > mediaComposer.maxFiles) { + showToast(`Можно отправить не более ${mediaComposer.maxFiles} файлов за раз`); + } + const limited = files.slice(0, mediaComposer.maxFiles); + if (mediaComposer.open && mediaComposer.items.length) { + void mediaComposer.addFiles(limited); + return; + } + void mediaComposer.openWithFiles(limited, options); + }, + [activeRoom, mediaComposer, view] + ); + + async function submitMediaCompose() { + if (!token || !activeRoom || !mediaComposer.items.length) return; setSending(true); try { - const contentType = resolveChatContentType(file); - const messageType = detectChatMessageType(file); - const metadata = { fileName: file.name, fileSize: file.size }; - - let uploadFile = file; - let uploadContentType = contentType; - let messageContent: string | undefined = messageType === 'FILE' ? file.name : undefined; - let isEncrypted = false; - - if (activeRoomIsE2E) { - if (!user?.id || !peerE2eKey) { - showToast('Секретный чат недоступен без ключей E2E у обоих участников'); - return; - } - const encryptedBuffer = await encryptE2EBlob(user.id, peerE2eKey, await file.arrayBuffer()); - uploadFile = new File([encryptedBuffer], `${file.name}.lendryenc`, { type: 'application/octet-stream' }); - uploadContentType = 'application/octet-stream'; - messageContent = await encryptOutgoing({ - kind: e2eKindFromMessageType(messageType), - fileName: file.name, - mimeType: contentType, - fileSize: file.size - }); - isEncrypted = true; - } - - const presigned = await apiFetch( - `/media/chat/${activeRoom.id}/media/upload-url`, - { - method: 'POST', - body: JSON.stringify({ contentType: uploadContentType, fileName: uploadFile.name }) - }, - token - ); - await uploadMediaObject(presigned, uploadFile, uploadContentType); - const message = await sendChatMessage( - activeRoom.id, - { - type: messageType, - storageKey: presigned.storageKey, - mimeType: uploadContentType, - content: messageContent, - isEncrypted, - metadataJson: JSON.stringify(activeRoomIsE2E ? { e2e: true, ...metadata } : metadata) - }, - token - ); - setMessages((current) => [...current, message]); + const uploaded = await uploadChatMediaBatch({ + roomId: activeRoom.id, + token, + items: mediaComposer.items.map((item) => ({ + file: item.file, + sendAsFile: item.sendAsFile, + width: item.width, + height: item.height + })), + caption: mediaComposer.caption, + isE2E: activeRoomIsE2E, + userId: user?.id, + peerE2eKey, + encryptOutgoing + }); + setMessages((current) => [...current, ...uploaded]); + mediaComposer.close(); void loadRooms(); } catch (error) { - showToast(getApiErrorMessage(error, 'Не удалось отправить файл') ?? 'Ошибка'); + showToast(getApiErrorMessage(error, 'Не удалось отправить файлы') ?? 'Ошибка'); } finally { setSending(false); } } + useEffect(() => { + if (!activeRoom || view !== 'chat') return; + function handlePaste(event: ClipboardEvent) { + const files = extractFilesFromClipboard(event.clipboardData); + if (!files.length) return; + event.preventDefault(); + queueMediaFiles(files, { sendAsFile: false }); + } + window.addEventListener('paste', handlePaste); + return () => window.removeEventListener('paste', handlePaste); + }, [activeRoom, queueMediaFiles, view]); + async function submitPoll() { if (!token || !activeRoom) return; const options = pollOptions.map((item) => item.trim()).filter(Boolean); @@ -946,7 +958,40 @@ export function MiniFamilyChat() { }} /> ) : ( - <> +
{ + event.preventDefault(); + dragDepthRef.current += 1; + setMediaDropVisible(true); + }} + onDragLeave={(event) => { + event.preventDefault(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setMediaDropVisible(false); + }} + onDragOver={(event) => event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + dragDepthRef.current = 0; + setMediaDropVisible(false); + const files = extractFilesFromDataTransfer(event.dataTransfer); + if (files.length) queueMediaFiles(files, { sendAsFile: false }); + }} + > + { + dragDepthRef.current = 0; + setMediaDropVisible(false); + queueMediaFiles(files, { sendAsFile: true }); + }} + onDropCompressed={(files) => { + dragDepthRef.current = 0; + setMediaDropVisible(false); + queueMediaFiles(files, { sendAsFile: false }); + }} + /> {pinnedMessage ? (
)} - +
)} ) : null} @@ -1335,14 +1380,30 @@ export function MiniFamilyChat() { { - const file = event.target.files?.[0]; - if (file) void uploadChatFile(file); + const files = Array.from(event.target.files ?? []); + if (files.length) queueMediaFiles(files); event.target.value = ''; }} /> + void mediaComposer.addFiles(files)} + onClose={mediaComposer.close} + onSend={() => void submitMediaCompose()} + /> ((resolve, reject) => { + const url = URL.createObjectURL(file); + const image = new Image(); + image.onload = () => { + URL.revokeObjectURL(url); + resolve(image); + }; + image.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Не удалось прочитать изображение')); + }; + image.src = url; + }); +} + +function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number) { + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error('Не удалось сжать изображение')); + return; + } + resolve(blob); + }, + type, + quality + ); + }); +} + +export function isCompressibleImageFile(file: File) { + const type = (file.type || '').toLowerCase(); + if (type.startsWith('image/') && !type.includes('gif') && !type.includes('svg')) { + return true; + } + return /\.(jpe?g|png|webp|heic|heif|bmp)$/i.test(file.name); +} + +export async function compressChatImage( + file: File, + options?: { maxEdge?: number; quality?: number } +) { + const maxEdge = options?.maxEdge ?? DEFAULT_MAX_EDGE; + const quality = options?.quality ?? DEFAULT_QUALITY; + + if (!isCompressibleImageFile(file)) { + return { file, width: undefined, height: undefined, compressed: false }; + } + + const image = await loadImageFromFile(file); + const width = image.naturalWidth || image.width; + const height = image.naturalHeight || image.height; + if (!width || !height) { + return { file, width: undefined, height: undefined, compressed: false }; + } + + const scale = Math.min(1, maxEdge / Math.max(width, height)); + const targetWidth = Math.max(1, Math.round(width * scale)); + const targetHeight = Math.max(1, Math.round(height * scale)); + + if (scale >= 1 && file.size <= 350_000) { + return { file, width, height, compressed: false }; + } + + const canvas = document.createElement('canvas'); + canvas.width = targetWidth; + canvas.height = targetHeight; + const context = canvas.getContext('2d'); + if (!context) { + return { file, width, height, compressed: false }; + } + + context.drawImage(image, 0, 0, targetWidth, targetHeight); + + const outputType = file.type === 'image/png' ? 'image/jpeg' : file.type || 'image/jpeg'; + const blob = await canvasToBlob(canvas, outputType, quality); + const extension = outputType.includes('webp') ? 'webp' : 'jpg'; + const baseName = file.name.replace(/\.[^.]+$/, '') || 'image'; + const compressedFile = new File([blob], `${baseName}.${extension}`, { type: outputType, lastModified: Date.now() }); + + return { + file: compressedFile, + width: targetWidth, + height: targetHeight, + compressed: true + }; +} + +export async function readImageDimensions(file: File) { + if (!isCompressibleImageFile(file)) { + return { width: undefined, height: undefined }; + } + try { + const image = await loadImageFromFile(file); + return { + width: image.naturalWidth || image.width, + height: image.naturalHeight || image.height + }; + } catch { + return { width: undefined, height: undefined }; + } +} + +export function createFilePreviewUrl(file: File) { + if (file.type.startsWith('image/')) { + return URL.createObjectURL(file); + } + return null; +} diff --git a/apps/frontend/lib/chat-media-album.ts b/apps/frontend/lib/chat-media-album.ts new file mode 100644 index 0000000..de30e57 --- /dev/null +++ b/apps/frontend/lib/chat-media-album.ts @@ -0,0 +1,75 @@ +import type { ChatMessage } from '@/lib/api'; + +export interface ChatMediaMetadata { + fileName?: string; + fileSize?: number; + durationMs?: number; + albumId?: string; + albumIndex?: number; + albumCount?: number; + width?: number; + height?: number; + sendAsFile?: boolean; + e2e?: boolean; +} + +export function parseChatMediaMetadata(metadataJson?: string): ChatMediaMetadata { + if (!metadataJson) return {}; + try { + return JSON.parse(metadataJson) as ChatMediaMetadata; + } catch { + return {}; + } +} + +export type ChatDisplayItem = + | { kind: 'message'; message: ChatMessage } + | { kind: 'album'; albumId: string; messages: ChatMessage[] }; + +export function groupChatMessagesForDisplay(messages: ChatMessage[]): ChatDisplayItem[] { + const items: ChatDisplayItem[] = []; + let index = 0; + + while (index < messages.length) { + const message = messages[index]!; + const meta = parseChatMediaMetadata(message.metadataJson); + + if (meta.albumId && message.storageKey) { + const albumMessages: ChatMessage[] = [message]; + let cursor = index + 1; + while (cursor < messages.length) { + const next = messages[cursor]!; + const nextMeta = parseChatMediaMetadata(next.metadataJson); + if ( + nextMeta.albumId === meta.albumId && + next.senderId === message.senderId && + next.storageKey + ) { + albumMessages.push(next); + cursor += 1; + continue; + } + break; + } + + albumMessages.sort((left, right) => { + const leftIndex = parseChatMediaMetadata(left.metadataJson).albumIndex ?? 0; + const rightIndex = parseChatMediaMetadata(right.metadataJson).albumIndex ?? 0; + return leftIndex - rightIndex; + }); + + items.push({ kind: 'album', albumId: meta.albumId, messages: albumMessages }); + index = cursor; + continue; + } + + items.push({ kind: 'message', message }); + index += 1; + } + + return items; +} + +export function collectChatImageMessages(messages: ChatMessage[]) { + return messages.filter((message) => message.type === 'IMAGE' && message.storageKey); +} diff --git a/apps/frontend/lib/chat-media-upload.ts b/apps/frontend/lib/chat-media-upload.ts new file mode 100644 index 0000000..9acf954 --- /dev/null +++ b/apps/frontend/lib/chat-media-upload.ts @@ -0,0 +1,121 @@ +import { + apiFetch, + PresignedUploadResponse, + sendChatMessage, + uploadMediaObject, + type ChatMessage +} from '@/lib/api'; +import { compressChatImage, isCompressibleImageFile, readImageDimensions } from '@/lib/chat-image-compress'; +import { detectChatMessageType, resolveChatContentType } from '@/lib/chat-media'; +import { e2eKindFromMessageType, encryptE2EBlob, type E2EMessagePayload } from '@/lib/e2e-chat'; + +export interface ChatMediaUploadItem { + file: File; + sendAsFile: boolean; + width?: number; + height?: number; +} + +type EncryptOutgoing = (payload: E2EMessagePayload) => Promise; + +export async function uploadChatMediaBatch(params: { + roomId: string; + token: string; + items: ChatMediaUploadItem[]; + caption?: string; + isE2E: boolean; + userId?: string; + peerE2eKey?: string | null; + encryptOutgoing?: EncryptOutgoing; +}): Promise { + const { roomId, token, items, caption, isE2E, userId, peerE2eKey, encryptOutgoing } = params; + if (!items.length) return []; + + const albumId = crypto.randomUUID(); + const created: ChatMessage[] = []; + + for (let index = 0; index < items.length; index += 1) { + const item = items[index]!; + let file = item.file; + let width = item.width; + let height = item.height; + + const shouldCompress = isCompressibleImageFile(file) && !item.sendAsFile; + if (shouldCompress) { + const compressed = await compressChatImage(file); + file = compressed.file; + width = compressed.width ?? width; + height = compressed.height ?? height; + } else if (isCompressibleImageFile(file) && (!width || !height)) { + const dimensions = await readImageDimensions(file); + width = dimensions.width; + height = dimensions.height; + } + + const contentType = resolveChatContentType(file); + const messageType = item.sendAsFile && isCompressibleImageFile(item.file) ? 'FILE' : detectChatMessageType(file); + const metadata = { + fileName: item.file.name, + fileSize: file.size, + albumId, + albumIndex: index, + albumCount: items.length, + ...(width ? { width } : {}), + ...(height ? { height } : {}), + ...(item.sendAsFile ? { sendAsFile: true } : {}) + }; + + let uploadFile = file; + let uploadContentType = contentType; + let messageContent: string | undefined = + index === 0 && caption?.trim() + ? caption.trim() + : messageType === 'FILE' + ? item.file.name + : undefined; + let isEncrypted = false; + + if (isE2E) { + if (!userId || !peerE2eKey || !encryptOutgoing) { + throw new Error('Секретный чат недоступен без ключей E2E у обоих участников'); + } + const encryptedBuffer = await encryptE2EBlob(userId, peerE2eKey, await file.arrayBuffer()); + uploadFile = new File([encryptedBuffer], `${item.file.name}.lendryenc`, { type: 'application/octet-stream' }); + uploadContentType = 'application/octet-stream'; + messageContent = await encryptOutgoing({ + kind: e2eKindFromMessageType(messageType), + fileName: item.file.name, + mimeType: contentType, + fileSize: file.size, + text: index === 0 ? caption?.trim() : undefined + }); + isEncrypted = true; + } + + const presigned = await apiFetch( + `/media/chat/${roomId}/media/upload-url`, + { + method: 'POST', + body: JSON.stringify({ contentType: uploadContentType, fileName: uploadFile.name }) + }, + token + ); + + await uploadMediaObject(presigned, uploadFile, uploadContentType); + const message = await sendChatMessage( + roomId, + { + type: messageType, + storageKey: presigned.storageKey, + mimeType: uploadContentType, + content: messageContent, + isEncrypted, + metadataJson: JSON.stringify(isE2E ? { e2e: true, ...metadata } : metadata) + }, + token + ); + created.push(message); + } + + return created; +} diff --git a/apps/sso-core/prisma/schema.prisma b/apps/sso-core/prisma/schema.prisma index 41ceb07..bddf2c2 100644 --- a/apps/sso-core/prisma/schema.prisma +++ b/apps/sso-core/prisma/schema.prisma @@ -408,6 +408,7 @@ model ChatRoom { group FamilyGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) members ChatRoomMember[] messages ChatMessage[] + botChats BotChat[] @@index([groupId, type]) @@index([groupId, type, peerUserId]) @@ -571,19 +572,23 @@ model BotChat { id String @id @default(uuid()) botId String recipientUserId String? + roomId String? telegramChatId BigInt chatType String @default("private") nextInboundMessageId Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade) + room ChatRoom? @relation(fields: [roomId], references: [id], onDelete: Cascade) messages BotMessage[] inboundMessages BotInboundMessage[] menuButton ChatMenuButton? @@unique([botId, recipientUserId]) + @@unique([botId, roomId]) @@unique([botId, telegramChatId]) @@index([botId]) + @@index([roomId]) } model ChatMenuButton { diff --git a/apps/sso-core/src/domain/bot/bot-api.service.ts b/apps/sso-core/src/domain/bot/bot-api.service.ts index eba6395..71cc4f6 100644 --- a/apps/sso-core/src/domain/bot/bot-api.service.ts +++ b/apps/sso-core/src/domain/bot/bot-api.service.ts @@ -204,7 +204,7 @@ export class BotApiService { payload: this.serializer.buildStoredPayload(parsedMarkup) }); - await this.publishBotMessage(context.recipient.id, bot as ValidatedBot, context.chat, message, parsedMarkup.internal); + await this.publishBotMessage(context.recipient?.id ?? null, bot as ValidatedBot, context.chat, message, parsedMarkup.internal); return message; } @@ -247,14 +247,16 @@ export class BotApiService { }; } - const chat = roomId - ? await this.resolveSharedFamilyBotChat(bot.id, userId, roomId) + const privateChat = roomId + ? await this.resolvePrivateBotChatForRoom(bot.id, userId, roomId) : await this.prisma.botChat.findUnique({ where: { botId_recipientUserId: { botId: bot.id, recipientUserId: userId } }, include: { menuButton: true } }); - const composer = this.resolveComposerForBot(bot, chat?.menuButton?.menuButton ?? null); + const groupChat = roomId ? await this.ensureRoomBotChat(bot.id, roomId) : null; + + const composer = this.resolveComposerForBot(bot, privateChat?.menuButton?.menuButton ?? null); const emptyResponse = { messages: [] as Array>, botUsername: bot.username, @@ -266,25 +268,31 @@ export class BotApiService { manageWebAppUrl: buildBotManageWebAppUrl(bot.id) }; - if (!chat) { + if (!privateChat && !groupChat) { return emptyResponse; } + const chatIds = [privateChat?.id, groupChat?.id].filter(Boolean) as string[]; + const [outbound, inbound] = await Promise.all([ this.prisma.botMessage.findMany({ - where: { botChatId: chat.id }, - orderBy: { createdAt: 'asc' } + where: { botChatId: { in: chatIds } }, + orderBy: { createdAt: 'asc' }, + include: { botChat: { select: { chatType: true, roomId: true } } } }), - this.prisma.botInboundMessage.findMany({ - where: { botChatId: chat.id }, - orderBy: { createdAt: 'asc' } - }) + privateChat + ? this.prisma.botInboundMessage.findMany({ + where: { botChatId: privateChat.id, senderUserId: userId }, + orderBy: { createdAt: 'asc' } + }) + : Promise.resolve([]) ]); const messages = [ ...inbound.map((item) => ({ id: `in-${item.id}`, direction: 'in' as const, + scope: 'private' as const, text: item.text ?? '', messageType: 'text', messageId: item.telegramMsgId, @@ -296,9 +304,11 @@ export class BotApiService { ...outbound.map((item) => { const payload = this.serializer.readPayload(item.payload); const replyMarkup = payload.telegramReplyMarkup ?? payload.replyMarkup; + const isBroadcast = item.botChat.chatType === 'group'; return { - id: `out-${item.id}`, + id: `${isBroadcast ? 'broadcast' : 'out'}-${item.id}`, direction: 'out' as const, + scope: isBroadcast ? ('broadcast' as const) : ('private' as const), text: item.text ?? '', messageType: item.messageType ?? 'text', messageId: item.telegramMsgId, @@ -338,7 +348,7 @@ export class BotApiService { }; } - private async resolveSharedFamilyBotChat(botId: string, viewerUserId: string, roomId: string) { + private async resolvePrivateBotChatForRoom(botId: string, viewerUserId: string, roomId: string) { const room = await this.prisma.chatRoom.findUnique({ where: { id: roomId }, include: { @@ -352,14 +362,62 @@ export class BotApiService { 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 } }, + + const existing = await this.prisma.botChat.findUnique({ + where: { botId_recipientUserId: { botId, recipientUserId: viewerUserId } }, include: { menuButton: true } }); + if (existing) { + return existing; + } + + const telegramChatId = deriveTelegramChatId(`${botId}:${viewerUserId}`); + try { + return await this.prisma.botChat.create({ + data: { + botId, + recipientUserId: viewerUserId, + telegramChatId, + chatType: 'private' + }, + include: { menuButton: true } + }); + } catch { + return this.prisma.botChat.findUnique({ + where: { botId_recipientUserId: { botId, recipientUserId: viewerUserId } }, + include: { menuButton: true } + }); + } + } + + async ensureRoomBotChat(botId: string, roomId: string) { + const existing = await this.prisma.botChat.findUnique({ + where: { botId_roomId: { botId, roomId } } + }); + if (existing) { + return existing; + } + + const telegramChatId = deriveTelegramChatId(`${botId}:room:${roomId}`); + try { + return await this.prisma.botChat.create({ + data: { + botId, + roomId, + telegramChatId, + chatType: 'group', + recipientUserId: null + } + }); + } catch { + const fallback = await this.prisma.botChat.findUnique({ + where: { botId_roomId: { botId, roomId } } + }); + if (!fallback) { + throw new Error('Не удалось создать групповой чат бота'); + } + return fallback; + } } private async setChatMenuButton(bot: ValidatedBot, payload: SetChatMenuButtonPayload) { @@ -444,7 +502,9 @@ export class BotApiService { }, override ); - await this.publishMenuButtonUpdate(context.recipient.id, bot, composer); + if (context.recipient) { + await this.publishMenuButtonUpdate(context.recipient.id, bot, composer); + } return this.wrapOk(true); } @@ -485,7 +545,7 @@ export class BotApiService { payload: this.serializer.buildStoredPayload(parsedMarkup) }); - await this.publishBotMessage(context.recipient.id, bot, context.chat, message, parsedMarkup.internal); + await this.publishBotMessage(context.recipient?.id ?? null, bot, context.chat, message, parsedMarkup.internal); return this.wrapOk(this.buildTelegramMessage(bot, context, message)); } @@ -509,7 +569,7 @@ export class BotApiService { payload: this.serializer.buildStoredPayload(parsedMarkup) }); - await this.publishBotMessage(context.recipient.id, bot, context.chat, message, parsedMarkup.internal, 'photo', photo); + await this.publishBotMessage(context.recipient?.id ?? null, bot, context.chat, message, parsedMarkup.internal, 'photo', photo); return this.wrapOk(this.buildTelegramMessage(bot, context, message)); } @@ -533,7 +593,7 @@ export class BotApiService { payload: this.serializer.buildStoredPayload(parsedMarkup) }); - await this.publishBotMessage(context.recipient.id, bot, context.chat, message, parsedMarkup.internal, 'document', document); + await this.publishBotMessage(context.recipient?.id ?? null, bot, context.chat, message, parsedMarkup.internal, 'document', document); return this.wrapOk(this.buildTelegramMessage(bot, context, message)); } @@ -570,7 +630,7 @@ export class BotApiService { } }); - await this.publishBotMessageEdited(located.recipient.id, bot, located.chat, updated, parsedMarkup.internal); + await this.publishBotMessageEdited(located.recipient?.id ?? null, bot, located.chat, updated, parsedMarkup.internal); return this.wrapOk(this.buildTelegramMessage(bot, located, updated)); } @@ -599,7 +659,7 @@ export class BotApiService { } }); - await this.publishBotMessageEdited(located.recipient.id, bot, located.chat, updated, parsedMarkup.internal); + await this.publishBotMessageEdited(located.recipient?.id ?? null, bot, located.chat, updated, parsedMarkup.internal); return this.wrapOk(this.buildTelegramMessage(bot, located, updated)); } @@ -626,8 +686,8 @@ export class BotApiService { return this.wrapOk({ id: Number(context.chat.telegramChatId), type: context.chat.chatType, - first_name: context.recipient.displayName, - ...(context.recipient.username ? { username: context.recipient.username } : {}), + first_name: context.recipient?.displayName ?? bot.name, + ...(context.recipient?.username ? { username: context.recipient.username } : {}), ...(pinned ? { pinned_message: this.buildTelegramMessage(bot, context, pinned) } : {}) }); } @@ -676,7 +736,7 @@ export class BotApiService { data: pinned ? { isPinned: true, pinnedAt: new Date() } : { isPinned: false, pinnedAt: null } }); - await this.publishBotMessagePinned(context.recipient.id, bot, context.chat, updated); + await this.publishBotMessagePinned(context.recipient?.id ?? null, bot, context.chat, updated); return this.wrapOk(true); } @@ -838,8 +898,34 @@ export class BotApiService { } private async resolveChatContext(botId: string, chatIdRaw: string) { + const numericChatId = chatIdRaw.match(/^\d+$/) ? BigInt(chatIdRaw) : null; + if (numericChatId) { + const chat = await this.prisma.botChat.findUnique({ + where: { botId_telegramChatId: { botId, telegramChatId: numericChatId } } + }); + if (!chat) { + return null; + } + if (chat.chatType === 'group') { + return { recipient: null, chat }; + } + if (!chat.recipientUserId) { + return null; + } + const recipient = await this.prisma.user.findUnique({ + where: { id: chat.recipientUserId }, + select: { id: true, displayName: true, username: true, status: true, deletedAt: true } + }); + if (!recipient || recipient.deletedAt || recipient.status !== 'ACTIVE') { + return null; + } + return { recipient, chat }; + } + const recipient = await this.resolveRecipient(chatIdRaw, botId); - if (!recipient) return null; + if (!recipient) { + return null; + } const chat = await this.ensureBotChat(botId, recipient.id, chatIdRaw); return { recipient, chat }; @@ -848,7 +934,7 @@ export class BotApiService { private buildTelegramMessage( bot: ValidatedBot, context: { - recipient: { id: string; displayName: string; username: string | null }; + recipient: { id: string; displayName: string; username: string | null } | null; chat: { telegramChatId: bigint; chatType: string }; }, message: { @@ -869,16 +955,16 @@ export class BotApiService { chat: { telegramChatId: context.chat.telegramChatId, chatType: context.chat.chatType, - recipientDisplayName: context.recipient.displayName, - recipientUsername: context.recipient.username + recipientDisplayName: context.recipient?.displayName ?? bot.name, + recipientUsername: context.recipient?.username ?? null } }); } private async publishBotMessage( - userId: string, + userId: string | null, bot: ValidatedBot, - chat: { telegramChatId: bigint; chatType: string }, + chat: { telegramChatId: bigint; chatType: string; roomId?: string | null }, message: { telegramMsgId: number; text?: string | null; @@ -900,16 +986,25 @@ export class BotApiService { messageType: message.messageType ?? mediaType ?? 'text', mediaUrl: message.mediaUrl ?? mediaUrl ?? null, replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null, - telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null + telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null, + scope: chat.chatType === 'group' ? 'broadcast' : 'private', + roomId: chat.roomId ?? null }; - await this.notifications.publishRealtime(userId, 'bot_message', bot.name, message.text ?? '', payload); - await this.publishToSharedBotRoomMembers(userId, bot, 'bot_message', message.text ?? '', payload); + + if (chat.chatType === 'group' && chat.roomId) { + await this.publishToRoomMembers(chat.roomId, bot, 'bot_message', message.text ?? '', payload); + return; + } + + if (userId) { + await this.notifications.publishRealtime(userId, 'bot_message', bot.name, message.text ?? '', payload); + } } private async publishBotMessagePinned( - userId: string, + userId: string | null, bot: ValidatedBot, - chat: { telegramChatId: bigint; chatType: string }, + chat: { telegramChatId: bigint; chatType: string; roomId?: string | null }, message: { telegramMsgId: number; isPinned?: boolean; @@ -922,16 +1017,25 @@ export class BotApiService { chatId: chat.telegramChatId.toString(), messageId: message.telegramMsgId, isPinned: Boolean(message.isPinned), - pinnedAt: message.pinnedAt?.toISOString() ?? null + pinnedAt: message.pinnedAt?.toISOString() ?? null, + scope: chat.chatType === 'group' ? 'broadcast' : 'private', + roomId: chat.roomId ?? null }; - await this.notifications.publishRealtime(userId, 'bot_message_pinned', bot.name, '', payload); - await this.publishToSharedBotRoomMembers(userId, bot, 'bot_message_pinned', '', payload); + + if (chat.chatType === 'group' && chat.roomId) { + await this.publishToRoomMembers(chat.roomId, bot, 'bot_message_pinned', '', payload); + return; + } + + if (userId) { + await this.notifications.publishRealtime(userId, 'bot_message_pinned', bot.name, '', payload); + } } private async publishBotMessageEdited( - userId: string, + userId: string | null, bot: ValidatedBot, - chat: { telegramChatId: bigint; chatType: string }, + chat: { telegramChatId: bigint; chatType: string; roomId?: string | null }, message: { telegramMsgId: number; text?: string | null; @@ -953,41 +1057,46 @@ export class BotApiService { mediaUrl: message.mediaUrl ?? null, replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null, telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null, - editedAt: message.editedAt?.toISOString() ?? new Date().toISOString() + editedAt: message.editedAt?.toISOString() ?? new Date().toISOString(), + scope: chat.chatType === 'group' ? 'broadcast' : 'private', + roomId: chat.roomId ?? null }; - await this.notifications.publishRealtime(userId, 'bot_message_edited', bot.name, message.text ?? '', payload); - await this.publishToSharedBotRoomMembers(userId, bot, 'bot_message_edited', message.text ?? '', payload); + + if (chat.chatType === 'group' && chat.roomId) { + await this.publishToRoomMembers(chat.roomId, bot, 'bot_message_edited', message.text ?? '', payload); + return; + } + + if (userId) { + await this.notifications.publishRealtime(userId, 'bot_message_edited', bot.name, message.text ?? '', payload); + } } - private async publishToSharedBotRoomMembers( - ownerUserId: string, + private async publishToRoomMembers( + roomId: string, bot: ValidatedBot, eventType: string, message: string, payload: Record ) { - const rooms = await this.prisma.chatRoom.findMany({ - where: { - type: 'BOT', - botUsername: bot.username, - createdById: ownerUserId - }, + const room = await this.prisma.chatRoom.findUnique({ + where: { id: roomId }, include: { members: { include: { user: { select: { linkedBotId: true } } } } } }); - - const recipients = new Set(); - for (const room of rooms) { - for (const member of room.members) { - if (member.userId !== ownerUserId && !member.user.linkedBotId) { - recipients.add(member.userId); - } - } + if (!room || room.type !== 'BOT') { + return; } + const recipients = room.members + .filter((member) => !member.user.linkedBotId) + .map((member) => member.userId); + await Promise.all( - [...recipients].map((recipientId) => this.notifications.publishRealtime(recipientId, eventType, bot.name, message, payload)) + recipients.map((recipientId) => + this.notifications.publishRealtime(recipientId, eventType, bot.name, message, payload) + ) ); } diff --git a/apps/sso-core/src/domain/bot/bot-father-assistant.service.ts b/apps/sso-core/src/domain/bot/bot-father-assistant.service.ts index 7b0c59a..c006c6e 100644 --- a/apps/sso-core/src/domain/bot/bot-father-assistant.service.ts +++ b/apps/sso-core/src/domain/bot/bot-father-assistant.service.ts @@ -141,6 +141,22 @@ export class BotFatherAssistantService { ? ownBots.map((bot) => `• ${bot.name} (@${bot.username})\n bot_id: ${bot.id}\n chat_id: ${deriveTelegramChatId(`${bot.id}:${senderUserId}`).toString()}`) : ['У вас пока нет созданных ботов.']; + const broadcastRooms = ownBots.length + ? await this.prisma.chatRoom.findMany({ + where: { + type: 'BOT', + botUsername: { in: ownBots.map((bot) => bot.username) } + }, + select: { id: true, name: true, botUsername: true } + }) + : []; + + const broadcastLines = broadcastRooms.map((room) => { + const bot = ownBots.find((item) => item.username === room.botUsername); + if (!bot) return null; + return `• ${room.name}\n group_chat_id: ${deriveTelegramChatId(`${bot.id}:room:${room.id}`).toString()}`; + }).filter(Boolean); + await this.botApi.sendInternalBotMessage( botId, senderUserId, @@ -155,11 +171,16 @@ export class BotFatherAssistantService { '', `chat_id с ${GET_MY_ID_DISPLAY_NAME}: ${currentChatId}`, '', - 'chat_id для ваших ботов:', + 'chat_id для ваших ботов (личный диалог):', ...botLines, '', + broadcastLines.length ? 'group_chat_id для уведомлений в семейный чат:' : '', + ...broadcastLines, + '', + 'Личный chat_id — для ответов конкретному пользователю.', + 'group_chat_id — для уведомлений, которые должны видеть все участники чата с ботом.', 'Используйте нужный chat_id в методах sendMessage, editMessageText, pinChatMessage и getChat.' - ].join('\n') + ].filter(Boolean).join('\n') ); } diff --git a/apps/sso-core/src/domain/bot/bot-inbound.service.ts b/apps/sso-core/src/domain/bot/bot-inbound.service.ts index 26c5240..176c1b1 100644 --- a/apps/sso-core/src/domain/bot/bot-inbound.service.ts +++ b/apps/sso-core/src/domain/bot/bot-inbound.service.ts @@ -214,23 +214,23 @@ export class BotInboundService { } private async resolveSharedFamilyBotChat(botId: string, senderUserId: string, roomId: string) { + await this.assertBotRoomAccess(botId, senderUserId, roomId); + return this.ensureBotChat(botId, senderUserId); + } + + private async assertBotRoomAccess(botId: string, userId: 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)) { + if (!room || room.type !== 'BOT' || !room.members.some((member) => member.userId === userId)) { 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); } } diff --git a/apps/sso-core/src/domain/chat.service.ts b/apps/sso-core/src/domain/chat.service.ts index 58ac894..ab8a55c 100644 --- a/apps/sso-core/src/domain/chat.service.ts +++ b/apps/sso-core/src/domain/chat.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../infra/prisma.service'; import { MinioService } from '../infra/minio.service'; +import { deriveTelegramChatId } from './bot/bot-token.util'; import { resolveVerificationIcon } from './verification.constants'; import { FamilyService } from './family.service'; import { NotificationsService } from './notifications.service'; @@ -88,7 +89,7 @@ export class ChatService { : undefined; if (room.type === 'BOT' && room.botUsername) { - const botPreview = await this.getBotRoomPreview(userId, room.botUsername); + const botPreview = await this.getBotRoomPreview(userId, room.botUsername, room.id); if (botPreview) { lastMessage = botPreview; } @@ -1203,10 +1204,13 @@ export class ChatService { data: { botUsername, peerUserId: botUserId, name: botDisplayName } }); } + const linkedBotId = + (await this.prisma.user.findUnique({ where: { id: botUserId }, select: { linkedBotId: true } }))?.linkedBotId ?? ''; + await this.ensureRoomBotChat(linkedBotId, existing.id); return existing; } - return this.prisma.chatRoom.create({ + const room = await this.prisma.chatRoom.create({ data: { groupId, type: 'BOT', @@ -1220,9 +1224,40 @@ export class ChatService { } } }); + + const linkedBotId = + (await this.prisma.user.findUnique({ where: { id: botUserId }, select: { linkedBotId: true } }))?.linkedBotId ?? ''; + await this.ensureRoomBotChat(linkedBotId, room.id); + + return room; } - private async getBotRoomPreview(userId: string, botUsername: string) { + private async ensureRoomBotChat(botId: string, roomId: string) { + if (!botId) return; + const existing = await this.prisma.botChat.findUnique({ + where: { botId_roomId: { botId, roomId } } + }); + if (existing) return existing; + + const telegramChatId = deriveTelegramChatId(`${botId}:room:${roomId}`); + try { + return await this.prisma.botChat.create({ + data: { + botId, + roomId, + telegramChatId, + chatType: 'group', + recipientUserId: null + } + }); + } catch { + return this.prisma.botChat.findUnique({ + where: { botId_roomId: { botId, roomId } } + }); + } + } + + private async getBotRoomPreview(userId: string, botUsername: string, roomId?: string) { const bot = await this.prisma.bot.findFirst({ where: { OR: [{ username: botUsername }, { username: `${botUsername.replace(/_bot$/i, '')}_bot` }] @@ -1231,18 +1266,24 @@ export class ChatService { }); if (!bot) return undefined; - const chat = await this.prisma.botChat.findUnique({ + const privateChat = await this.prisma.botChat.findUnique({ where: { botId_recipientUserId: { botId: bot.id, recipientUserId: userId } } }); - if (!chat) return undefined; + const groupChat = roomId ? await this.ensureRoomBotChat(bot.id, roomId) : null; + + if (!privateChat && !groupChat) return undefined; + + const chatIds = [privateChat?.id, groupChat?.id].filter(Boolean) as string[]; const [lastInbound, lastOutbound] = await Promise.all([ - this.prisma.botInboundMessage.findFirst({ - where: { botChatId: chat.id }, - orderBy: { createdAt: 'desc' } - }), + privateChat + ? this.prisma.botInboundMessage.findFirst({ + where: { botChatId: privateChat.id, senderUserId: userId }, + orderBy: { createdAt: 'desc' } + }) + : Promise.resolve(null), this.prisma.botMessage.findFirst({ - where: { botChatId: chat.id }, + where: { botChatId: { in: chatIds } }, orderBy: { createdAt: 'desc' } }) ]); @@ -1252,8 +1293,8 @@ export class ChatService { const latest = candidates.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]!; return { - id: `bot-preview-${chat.id}`, - roomId: '', + id: `bot-preview-${privateChat?.id ?? groupChat?.id ?? bot.id}`, + roomId: roomId ?? '', senderId: userId, senderName: bot.name, senderHasAvatar: false, diff --git a/shared/proto/bot.proto b/shared/proto/bot.proto index 3cbac2d..298c714 100644 --- a/shared/proto/bot.proto +++ b/shared/proto/bot.proto @@ -224,6 +224,7 @@ message BotChatMessageItem { optional string replyMarkupJson = 7; bool isPinned = 8; optional string pinnedAt = 9; + optional string scope = 10; } message ListBotChatMessagesResponse { diff --git a/tauri_app/docker/build-android-apk.sh b/tauri_app/docker/build-android-apk.sh index 6b52210..2caa060 100644 --- a/tauri_app/docker/build-android-apk.sh +++ b/tauri_app/docker/build-android-apk.sh @@ -3,11 +3,17 @@ set -euo pipefail cd /workspace +# 1. Настройка переменных окружения export VITE_API_URL="${VITE_API_URL:-${PUBLIC_API_URL:-http://localhost:3000}}" export VITE_FRONTEND_URL="${VITE_FRONTEND_URL:-${PUBLIC_FRONTEND_URL:-http://localhost:3002}}" export ANDROID_HOME="${ANDROID_HOME:-/opt/android-sdk}" export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME}}" +# Устанавливаем целевую версию Gradle Plugin. +# Можно переопределить через Docker: -e ANDROID_GRADLE_PLUGIN_VERSION=8.5.0 +export ANDROID_GRADLE_PLUGIN_VERSION="${ANDROID_GRADLE_PLUGIN_VERSION:-8.3.2}" + +# Поиск NDK if [ -z "${ANDROID_NDK_HOME:-}" ]; then NDK_DIR="$(find "${ANDROID_HOME}/ndk" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -n 1 || true)" if [ -n "${NDK_DIR}" ]; then @@ -18,19 +24,22 @@ fi echo "==> Android SDK: ${ANDROID_HOME}" echo "==> Android NDK: ${ANDROID_NDK_HOME:-не найден}" +echo "==> Целевая версия Android Gradle Plugin: ${ANDROID_GRADLE_PLUGIN_VERSION}" if [ -z "${ANDROID_NDK_HOME:-}" ] || [ ! -d "${ANDROID_NDK_HOME}" ]; then - echo "Android NDK не найден в ${ANDROID_HOME}/ndk. Используйте Android SDK image с предустановленным NDK или примонтируйте SDK с NDK." >&2 + echo "Ошибка: Android NDK не найден." >&2 exit 1 fi mkdir -p /out +# 2. Сборка веб-части echo "==> Проверка web-сборки tauri_app" npm --workspace @lendry/tauri-app run build cd /workspace/tauri_app +# 3. Инициализация проекта if [ -d "src-tauri/gen/android" ]; then echo "==> Очистка предыдущего Android проекта Tauri" rm -rf src-tauri/gen/android @@ -39,28 +48,29 @@ fi echo "==> Инициализация Android проекта Tauri" npm run android:init -- --ci || npm run android:init -if [ -n "${ANDROID_GRADLE_PLUGIN_VERSION:-}" ]; then - echo "==> Переопределение Android Gradle Plugin: ${ANDROID_GRADLE_PLUGIN_VERSION}" - find src-tauri/gen/android \ - -type f \ - \( -name '*.gradle' -o -name '*.gradle.kts' -o -name '*.toml' \) \ - -print0 \ - | xargs -0 sed -i -E "s/(com\\.android\\.tools\\.build:gradle:)[0-9]+\\.[0-9]+\\.[0-9]+/\\1${ANDROID_GRADLE_PLUGIN_VERSION}/g; s/(com\\.android\\.application[\"']? version[[:space:]]*=?[[:space:]]*[\"'])[0-9]+\\.[0-9]+\\.[0-9]+/\\1${ANDROID_GRADLE_PLUGIN_VERSION}/g" -fi +# 4. Принудительный патчинг всех файлов конфигурации Gradle +# Мы заменяем любые упоминания версии плагина на нашу стабильную версию +echo "==> Патчинг конфигурации Gradle на версию ${ANDROID_GRADLE_PLUGIN_VERSION}" +find src-tauri/gen/android -type f \( -name "*.gradle" -o -name "*.gradle.kts" -o -name "*.toml" \) -print0 | xargs -0 sed -i -E \ + -e "s/(com\.android\.tools\.build:gradle:)[0-9]+\.[0-9]+\.[0-9]+/\1${ANDROID_GRADLE_PLUGIN_VERSION}/g" \ + -e "s/(id\(?['\"]com\.android\.application['\"]? version ['\"])[0-9]+\.[0-9]+\.[0-9]+/\1${ANDROID_GRADLE_PLUGIN_VERSION}/g" \ + -e "s/(agp[[:space:]]*=[[:space:]]*['\"])[0-9]+\.[0-9]+\.[0-9]+/\1${ANDROID_GRADLE_PLUGIN_VERSION}/g" + +# 5. Сборка APK echo "==> Сборка Android APK" npm run tauri -- android build --apk +# 6. Копирование результата APK_PATH="$(find /workspace/tauri_app/src-tauri/gen/android -type f -name '*.apk' | sort | tail -n 1 || true)" if [ -z "${APK_PATH}" ] || [ ! -f "${APK_PATH}" ]; then - echo "APK не найден после сборки" >&2 - find /workspace/tauri_app/src-tauri/gen/android -maxdepth 8 -type f | sort >&2 || true + echo "Ошибка: APK не найден после сборки" >&2 exit 1 fi cp "${APK_PATH}" /out/lendry-id.apk chmod 0644 /out/lendry-id.apk -echo "==> APK готов: /out/lendry-id.apk" -ls -lh /out/lendry-id.apk +echo "==> APK успешно собран: /out/lendry-id.apk" +ls -lh /out/lendry-id.apk \ No newline at end of file