diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt index 1c7e18a..c9a4d66 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt @@ -32,8 +32,7 @@ class NetworkMediaRepository @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : MediaRepository { - override suspend fun uploadAndAttach( - messageId: Long, + override suspend fun uploadMedia( fileName: String, mimeType: String, bytes: ByteArray, @@ -68,15 +67,6 @@ class NetworkMediaRepository @Inject constructor( ) } } - - mediaApiService.createAttachment( - request = AttachmentCreateRequestDto( - messageId = messageId, - fileUrl = uploadInfo.fileUrl, - fileType = uploadPayload.mimeType, - fileSize = uploadPayload.bytes.size.toLong(), - ) - ) AppResult.Success( UploadedAttachment( fileUrl = uploadInfo.fileUrl, @@ -89,6 +79,33 @@ class NetworkMediaRepository @Inject constructor( } } + override suspend fun uploadAndAttach( + messageId: Long, + fileName: String, + mimeType: String, + bytes: ByteArray, + ): AppResult = withContext(ioDispatcher) { + when (val uploadResult = uploadMedia(fileName = fileName, mimeType = mimeType, bytes = bytes)) { + is AppResult.Success -> { + try { + mediaApiService.createAttachment( + request = AttachmentCreateRequestDto( + messageId = messageId, + fileUrl = uploadResult.data.fileUrl, + fileType = uploadResult.data.fileType, + fileSize = uploadResult.data.fileSize, + ) + ) + AppResult.Success(uploadResult.data) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + is AppResult.Error -> uploadResult + } + } + private fun prepareUploadPayload( fileName: String, mimeType: String, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt index 9e7b4eb..96a463e 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt @@ -10,6 +10,8 @@ import retrofit2.http.Query import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto import ru.daemonlord.messenger.data.message.dto.MessageForwardRequestDto import ru.daemonlord.messenger.data.message.dto.MessageForwardBulkRequestDto +import ru.daemonlord.messenger.data.message.dto.MessageMediaGroupCreateRequestDto +import ru.daemonlord.messenger.data.message.dto.MessageMediaGroupResponseDto import ru.daemonlord.messenger.data.message.dto.MessageReadDto import ru.daemonlord.messenger.data.message.dto.MessageReactionDto import ru.daemonlord.messenger.data.message.dto.MessageReactionToggleRequestDto @@ -41,6 +43,11 @@ interface MessageApiService { @Body request: MessageCreateRequestDto, ): MessageReadDto + @POST("/api/v1/messages/media-group") + suspend fun sendMediaGroup( + @Body request: MessageMediaGroupCreateRequestDto, + ): MessageMediaGroupResponseDto + @PUT("/api/v1/messages/{message_id}") suspend fun editMessage( @Path("message_id") messageId: Long, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt index 0f8c016..15a40cf 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt @@ -50,6 +50,51 @@ data class MessageCreateRequestDto( val replyToMessageId: Long? = null, ) +@Serializable +data class MessageMediaGroupAttachmentRequestDto( + @SerialName("file_url") + val fileUrl: String, + @SerialName("file_type") + val fileType: String, + @SerialName("file_size") + val fileSize: Long, + @SerialName("waveform_points") + val waveformPoints: List? = null, +) + +@Serializable +data class MessageMediaGroupCreateRequestDto( + @SerialName("chat_id") + val chatId: Long, + val text: String? = null, + @SerialName("client_message_id") + val clientMessageId: String, + @SerialName("reply_to_message_id") + val replyToMessageId: Long? = null, + val attachments: List, +) + +@Serializable +data class MessageMediaGroupAttachmentReadDto( + val id: Long, + @SerialName("message_id") + val messageId: Long, + @SerialName("file_url") + val fileUrl: String, + @SerialName("file_type") + val fileType: String, + @SerialName("file_size") + val fileSize: Long, + @SerialName("waveform_points") + val waveformPoints: List? = null, +) + +@Serializable +data class MessageMediaGroupResponseDto( + val message: MessageReadDto, + val attachments: List = emptyList(), +) + @Serializable data class MessageUpdateRequestDto( val text: String, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt index 31d1ca1..6a6d91d 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt @@ -26,11 +26,14 @@ import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.media.repository.MediaRepository import ru.daemonlord.messenger.domain.message.model.MessageItem +import ru.daemonlord.messenger.domain.message.model.OutgoingMediaItem import ru.daemonlord.messenger.domain.message.model.MessageReaction import ru.daemonlord.messenger.domain.message.repository.MessageRepository import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto import ru.daemonlord.messenger.data.message.dto.MessageForwardRequestDto import ru.daemonlord.messenger.data.message.dto.MessageForwardBulkRequestDto +import ru.daemonlord.messenger.data.message.dto.MessageMediaGroupAttachmentRequestDto +import ru.daemonlord.messenger.data.message.dto.MessageMediaGroupCreateRequestDto import ru.daemonlord.messenger.data.message.dto.MessageReactionToggleRequestDto import ru.daemonlord.messenger.data.message.dto.MessageStatusUpdateRequestDto import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto @@ -395,6 +398,118 @@ class NetworkMessageRepository @Inject constructor( } } + override suspend fun sendMediaGroupMessage( + chatId: Long, + items: List, + caption: String?, + replyToMessageId: Long?, + ): AppResult = withContext(ioDispatcher) { + if (items.isEmpty()) { + return@withContext AppResult.Error(AppError.Server("Media group is empty")) + } + val messageType = inferMediaGroupType(items = items) + val tempId = -System.currentTimeMillis() + val now = java.time.Instant.now().toString() + val tempMessage = MessageEntity( + id = tempId, + chatId = chatId, + senderId = currentUserId ?: 0L, + senderDisplayName = null, + senderUsername = null, + senderAvatarUrl = null, + replyToMessageId = replyToMessageId, + replyPreviewText = null, + replyPreviewSenderName = null, + forwardedFromMessageId = null, + forwardedFromDisplayName = null, + type = messageType, + text = caption, + status = "pending", + attachmentWaveformJson = null, + createdAt = now, + updatedAt = null, + ) + messageDao.upsertMessages(listOf(tempMessage)) + chatDao.updateLastMessage( + chatId = chatId, + lastMessageText = caption, + lastMessageType = messageType, + lastMessageCreatedAt = now, + updatedSortAt = now, + ) + try { + val uploadedAttachments = mutableListOf() + for (item in items) { + when (val uploadResult = mediaRepository.uploadMedia( + fileName = item.fileName, + mimeType = item.mimeType, + bytes = item.bytes, + )) { + is AppResult.Success -> uploadedAttachments.add(uploadResult.data) + is AppResult.Error -> { + messageDao.deleteMessage(tempId) + return@withContext AppResult.Error(uploadResult.reason) + } + } + } + + val created = messageApiService.sendMediaGroup( + request = MessageMediaGroupCreateRequestDto( + chatId = chatId, + text = caption, + clientMessageId = UUID.randomUUID().toString(), + replyToMessageId = replyToMessageId, + attachments = uploadedAttachments.map { + MessageMediaGroupAttachmentRequestDto( + fileUrl = it.fileUrl, + fileType = it.fileType, + fileSize = it.fileSize, + ) + }, + ) + ) + + messageDao.deleteMessage(tempId) + messageDao.upsertMessages(listOf(created.message.toEntity())) + messageDao.upsertAttachments( + if (created.attachments.isNotEmpty()) { + created.attachments.map { attachment -> + MessageAttachmentEntity( + id = attachment.id, + messageId = attachment.messageId, + fileUrl = attachment.fileUrl, + fileType = attachment.fileType, + fileSize = attachment.fileSize, + waveformPointsJson = null, + ) + } + } else { + uploadedAttachments.mapIndexed { index, attachment -> + MessageAttachmentEntity( + id = tempId - index - 1, + messageId = created.message.id, + fileUrl = attachment.fileUrl, + fileType = attachment.fileType, + fileSize = attachment.fileSize, + waveformPointsJson = null, + ) + } + } + ) + chatDao.updateLastMessage( + chatId = chatId, + lastMessageText = created.message.text, + lastMessageType = created.message.type, + lastMessageCreatedAt = created.message.createdAt, + updatedSortAt = created.message.createdAt, + ) + AppResult.Success(Unit) + } catch (error: Throwable) { + messageDao.deleteMessage(tempId) + AppResult.Error(error.toAppError()) + } + } + override suspend fun sendImageUrlMessage( chatId: Long, imageUrl: String, @@ -589,6 +704,17 @@ class NetworkMessageRepository @Inject constructor( } } + private fun inferMediaGroupType(items: List): String { + val resolvedTypes = items.map { mapMimeToMessageType(mimeType = it.mimeType, fileName = it.fileName) } + return when { + resolvedTypes.all { it == "image" } -> "image" + resolvedTypes.all { it == "audio" || it == "voice" } -> "audio" + resolvedTypes.all { it == "video" } -> "video" + resolvedTypes.all { it == "image" || it == "video" } -> "video" + else -> "file" + } + } + private suspend fun flushPendingActions(chatId: Long) { val pending = pendingMessageActionDao.listPending(limit = 100) for (action in pending) { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/media/repository/MediaRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/media/repository/MediaRepository.kt index 8689f27..39212bb 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/media/repository/MediaRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/media/repository/MediaRepository.kt @@ -4,6 +4,12 @@ import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.media.model.UploadedAttachment interface MediaRepository { + suspend fun uploadMedia( + fileName: String, + mimeType: String, + bytes: ByteArray, + ): AppResult + suspend fun uploadAndAttach( messageId: Long, fileName: String, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/OutgoingMediaItem.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/OutgoingMediaItem.kt new file mode 100644 index 0000000..c44626f --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/OutgoingMediaItem.kt @@ -0,0 +1,7 @@ +package ru.daemonlord.messenger.domain.message.model + +data class OutgoingMediaItem( + val fileName: String, + val mimeType: String, + val bytes: ByteArray, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt index 6bb2030..0172ecf 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt @@ -3,6 +3,7 @@ package ru.daemonlord.messenger.domain.message.repository import kotlinx.coroutines.flow.Flow import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.message.model.MessageItem +import ru.daemonlord.messenger.domain.message.model.OutgoingMediaItem import ru.daemonlord.messenger.domain.message.model.MessageReaction interface MessageRepository { @@ -20,6 +21,12 @@ interface MessageRepository { caption: String? = null, replyToMessageId: Long? = null, ): AppResult + suspend fun sendMediaGroupMessage( + chatId: Long, + items: List, + caption: String? = null, + replyToMessageId: Long? = null, + ): AppResult suspend fun sendImageUrlMessage( chatId: Long, imageUrl: String, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SendMediaGroupMessageUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SendMediaGroupMessageUseCase.kt new file mode 100644 index 0000000..32f50c3 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SendMediaGroupMessageUseCase.kt @@ -0,0 +1,24 @@ +package ru.daemonlord.messenger.domain.message.usecase + +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.message.model.OutgoingMediaItem +import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import javax.inject.Inject + +class SendMediaGroupMessageUseCase @Inject constructor( + private val messageRepository: MessageRepository, +) { + suspend operator fun invoke( + chatId: Long, + items: List, + caption: String? = null, + replyToMessageId: Long? = null, + ): AppResult { + return messageRepository.sendMediaGroupMessage( + chatId = chatId, + items = items, + caption = caption, + replyToMessageId = replyToMessageId, + ) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index b6173e1..ff93834 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -28,6 +28,7 @@ import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.message.model.MessageItem +import ru.daemonlord.messenger.domain.message.model.OutgoingMediaItem import ru.daemonlord.messenger.domain.message.usecase.DeleteMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.EditMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.ForwardMessageBulkUseCase @@ -38,6 +39,7 @@ import ru.daemonlord.messenger.domain.message.usecase.MarkMessageDeliveredUseCas import ru.daemonlord.messenger.domain.message.usecase.MarkMessageReadUseCase import ru.daemonlord.messenger.domain.message.usecase.ObserveMessagesUseCase import ru.daemonlord.messenger.domain.message.usecase.SendImageUrlMessageUseCase +import ru.daemonlord.messenger.domain.message.usecase.SendMediaGroupMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.SendMediaMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase @@ -60,6 +62,7 @@ class ChatViewModel @Inject constructor( private val sendTextMessageUseCase: SendTextMessageUseCase, private val sendImageUrlMessageUseCase: SendImageUrlMessageUseCase, private val sendMediaMessageUseCase: SendMediaMessageUseCase, + private val sendMediaGroupMessageUseCase: SendMediaGroupMessageUseCase, private val editMessageUseCase: EditMessageUseCase, private val deleteMessageUseCase: DeleteMessageUseCase, private val markMessageDeliveredUseCase: MarkMessageDeliveredUseCase, @@ -668,36 +671,39 @@ class ChatViewModel @Inject constructor( _uiState.update { it.copy(isUploadingMedia = true, errorMessage = null) } val caption = uiState.value.inputText.trim().ifBlank { null } val replyToMessageId = uiState.value.replyToMessage?.id - items.forEachIndexed { index, item -> - when ( - val result = sendMediaMessageUseCase( - chatId = chatId, - fileName = item.fileName, - mimeType = item.mimeType, - bytes = item.bytes, - caption = if (index == 0) caption else null, - replyToMessageId = if (index == 0) replyToMessageId else null, - ) - ) { - is AppResult.Success -> Unit - is AppResult.Error -> { - _uiState.update { - it.copy( - isUploadingMedia = false, - errorMessage = result.reason.toUiMessage(), - ) - } - return@launch + when ( + val result = sendMediaGroupMessageUseCase( + chatId = chatId, + items = items.map { item -> + OutgoingMediaItem( + fileName = item.fileName, + mimeType = item.mimeType, + bytes = item.bytes, + ) + }, + caption = caption, + replyToMessageId = replyToMessageId, + ) + ) { + is AppResult.Success -> { + _uiState.update { + it.copy( + isUploadingMedia = false, + inputText = "", + replyToMessage = null, + editingMessage = null, + ) + } + } + + is AppResult.Error -> { + _uiState.update { + it.copy( + isUploadingMedia = false, + errorMessage = result.reason.toUiMessage(), + ) } } - } - _uiState.update { - it.copy( - isUploadingMedia = false, - inputText = "", - replyToMessage = null, - editingMessage = null, - ) } } } diff --git a/app/messages/router.py b/app/messages/router.py index 6db0b22..da631b2 100644 --- a/app/messages/router.py +++ b/app/messages/router.py @@ -7,6 +7,8 @@ from app.messages.schemas import ( MessageCreateRequest, MessageForwardBulkRequest, MessageForwardRequest, + MessageMediaGroupCreateRequest, + MessageMediaGroupRead, MessageReactionRead, MessageReactionToggleRequest, MessageRead, @@ -16,6 +18,7 @@ from app.messages.schemas import ( from app.messages.repository import get_message_by_id from app.messages.service import ( create_chat_message, + create_chat_media_group, delete_message, delete_message_for_all, forward_message, @@ -49,6 +52,21 @@ async def create_message( return message +@router.post("/media-group", response_model=MessageMediaGroupRead, status_code=status.HTTP_201_CREATED) +async def create_media_group( + payload: MessageMediaGroupCreateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> MessageMediaGroupRead: + result = await create_chat_media_group(db, sender_id=current_user.id, payload=payload) + await realtime_gateway.publish_message_created( + message=result.message, + sender_id=current_user.id, + client_message_id=payload.client_message_id, + ) + return result + + @router.get("/search", response_model=list[MessageRead]) async def search_messages_endpoint( query: str, diff --git a/app/messages/schemas.py b/app/messages/schemas.py index e365664..76b9bf3 100644 --- a/app/messages/schemas.py +++ b/app/messages/schemas.py @@ -3,6 +3,7 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, Field +from app.media.schemas import AttachmentRead from app.messages.models import MessageType @@ -30,6 +31,26 @@ class MessageCreateRequest(BaseModel): reply_to_message_id: int | None = None +class MessageMediaGroupAttachmentCreateRequest(BaseModel): + file_url: str = Field(min_length=1, max_length=1024) + file_type: str = Field(min_length=1, max_length=64) + file_size: int = Field(gt=0) + waveform_points: list[int] | None = Field(default=None, min_length=8, max_length=256) + + +class MessageMediaGroupCreateRequest(BaseModel): + chat_id: int + text: str | None = Field(default=None, max_length=4096) + client_message_id: str | None = Field(default=None, min_length=8, max_length=64) + reply_to_message_id: int | None = None + attachments: list[MessageMediaGroupAttachmentCreateRequest] = Field(min_length=1, max_length=10) + + +class MessageMediaGroupRead(BaseModel): + message: MessageRead + attachments: list[AttachmentRead] + + class MessageUpdateRequest(BaseModel): text: str = Field(min_length=1, max_length=4096) diff --git a/app/messages/service.py b/app/messages/service.py index 34e56ec..8c00815 100644 --- a/app/messages/service.py +++ b/app/messages/service.py @@ -9,13 +9,18 @@ from app.chats import repository as chats_repository from app.chats.models import ChatMemberRole, ChatType from app.chats.service import ensure_chat_membership from app.media import repository as media_repository +from app.media.schemas import AttachmentRead +from app.media.service import _allowed_file_url_prefixes, _decode_waveform, _normalize_waveform, _validate_media from app.messages import repository -from app.messages.models import Message +from app.messages.models import Message, MessageType from app.messages.spam_guard import enforce_message_spam_policy from app.messages.schemas import ( MessageCreateRequest, MessageForwardBulkRequest, MessageForwardRequest, + MessageMediaGroupCreateRequest, + MessageMediaGroupRead, + MessageRead, MessageReactionRead, MessageReactionToggleRequest, MessageStatusUpdateRequest, @@ -26,6 +31,32 @@ from app.users.repository import has_block_relation_between_users from app.users.service import can_user_receive_private_messages, get_user_by_id +def _infer_media_group_message_type(payload: MessageMediaGroupCreateRequest) -> MessageType: + normalized_types = [attachment.file_type.split(";", maxsplit=1)[0].strip().lower() for attachment in payload.attachments] + if normalized_types and all(item.startswith("image/") for item in normalized_types): + return MessageType.IMAGE + if normalized_types and all(item.startswith("audio/") for item in normalized_types): + return MessageType.AUDIO + if normalized_types and all(item.startswith("video/") for item in normalized_types): + return MessageType.VIDEO + if normalized_types and all(item.startswith("image/") or item.startswith("video/") for item in normalized_types): + return MessageType.VIDEO + return MessageType.FILE + + +def _serialize_attachment(attachment) -> AttachmentRead: + return AttachmentRead.model_validate( + { + "id": attachment.id, + "message_id": attachment.message_id, + "file_url": attachment.file_url, + "file_type": attachment.file_type, + "file_size": attachment.file_size, + "waveform_points": _decode_waveform(attachment.waveform_data), + } + ) + + async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: MessageCreateRequest) -> Message: await ensure_chat_membership(db, chat_id=payload.chat_id, user_id=sender_id) chat = await chats_repository.get_chat_by_id(db, payload.chat_id) @@ -101,6 +132,114 @@ async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: Mess return message +async def create_chat_media_group( + db: AsyncSession, + *, + sender_id: int, + payload: MessageMediaGroupCreateRequest, +) -> MessageMediaGroupRead: + await ensure_chat_membership(db, chat_id=payload.chat_id, user_id=sender_id) + chat = await chats_repository.get_chat_by_id(db, payload.chat_id) + if not chat: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found") + membership = await chats_repository.get_chat_member(db, chat_id=payload.chat_id, user_id=sender_id) + if not membership: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat") + if chat.type == ChatType.CHANNEL and membership.role == ChatMemberRole.MEMBER: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can post in channels") + if chat.type == ChatType.PRIVATE: + counterpart_id = await chats_repository.get_private_counterpart_user_id(db, chat_id=payload.chat_id, user_id=sender_id) + if counterpart_id and await has_block_relation_between_users(db, user_a_id=sender_id, user_b_id=counterpart_id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot send message due to block settings") + if counterpart_id: + counterpart = await get_user_by_id(db, counterpart_id) + if counterpart and not await can_user_receive_private_messages(db, target_user=counterpart, actor_user_id=sender_id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User does not accept private messages") + if payload.reply_to_message_id is not None: + reply_to = await repository.get_message_by_id(db, payload.reply_to_message_id) + if not reply_to or reply_to.chat_id != payload.chat_id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid reply target") + if payload.client_message_id: + existing = await repository.get_message_by_client_message_id( + db, + chat_id=payload.chat_id, + sender_id=sender_id, + client_message_id=payload.client_message_id, + ) + if existing: + existing_attachments = await media_repository.list_attachments_by_message_ids(db, message_ids=[existing.id]) + return MessageMediaGroupRead( + message=MessageRead.model_validate(existing), + attachments=[_serialize_attachment(attachment) for attachment in existing_attachments], + ) + + message_type = _infer_media_group_message_type(payload) + await enforce_message_spam_policy(user_id=sender_id, chat_id=payload.chat_id, text=payload.text) + + for attachment in payload.attachments: + _validate_media(attachment.file_type, attachment.file_size) + if not attachment.file_url.startswith(_allowed_file_url_prefixes()): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid file URL") + + try: + message = await repository.create_message( + db, + chat_id=payload.chat_id, + sender_id=sender_id, + reply_to_message_id=payload.reply_to_message_id, + forwarded_from_message_id=None, + message_type=message_type, + text=payload.text, + ) + created_attachments = [] + for attachment in payload.attachments: + normalized_waveform = _normalize_waveform(attachment.waveform_points) + created_attachments.append( + await media_repository.create_attachment( + db, + message_id=message.id, + file_url=attachment.file_url, + file_type=attachment.file_type, + file_size=attachment.file_size, + waveform_data=json.dumps(normalized_waveform, ensure_ascii=True) if normalized_waveform else None, + ) + ) + if payload.client_message_id: + await repository.create_message_idempotency_key( + db, + chat_id=payload.chat_id, + sender_id=sender_id, + client_message_id=payload.client_message_id, + message_id=message.id, + ) + await db.commit() + await db.refresh(message) + except IntegrityError: + await db.rollback() + if payload.client_message_id: + existing = await repository.get_message_by_client_message_id( + db, + chat_id=payload.chat_id, + sender_id=sender_id, + client_message_id=payload.client_message_id, + ) + if existing: + existing_attachments = await media_repository.list_attachments_by_message_ids(db, message_ids=[existing.id]) + return MessageMediaGroupRead( + message=MessageRead.model_validate(existing), + attachments=[_serialize_attachment(attachment) for attachment in existing_attachments], + ) + raise + try: + await dispatch_message_notifications(db, message) + except Exception: + pass + return MessageMediaGroupRead( + message=MessageRead.model_validate(message), + attachments=[_serialize_attachment(attachment) for attachment in created_attachments], + ) + + async def get_messages( db: AsyncSession, *, diff --git a/web/src/api/chats.ts b/web/src/api/chats.ts index a442ff9..10e3320 100644 --- a/web/src/api/chats.ts +++ b/web/src/api/chats.ts @@ -106,6 +106,42 @@ export async function sendMessageWithClientId( return data; } +export interface MediaGroupAttachmentInput { + file_url: string; + file_type: string; + file_size: number; + waveform_points?: number[] | null; +} + +export interface MediaGroupSendResponse { + message: Message; + attachments: Array<{ + id: number; + message_id: number; + file_url: string; + file_type: string; + file_size: number; + waveform_points?: number[] | null; + }>; +} + +export async function sendMediaGroupWithClientId( + chatId: number, + attachments: MediaGroupAttachmentInput[], + text: string | null, + clientMessageId: string, + replyToMessageId?: number +): Promise { + const { data } = await http.post("/messages/media-group", { + chat_id: chatId, + attachments, + text, + client_message_id: clientMessageId, + reply_to_message_id: replyToMessageId + }); + return data; +} + export interface UploadUrlResponse { upload_url: string; file_url: string; diff --git a/web/src/components/MessageComposer.tsx b/web/src/components/MessageComposer.tsx index e4afa2b..714a817 100644 --- a/web/src/components/MessageComposer.tsx +++ b/web/src/components/MessageComposer.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react"; -import { attachFile, editMessage, requestUploadUrl, sendMessageWithClientId, uploadToPresignedUrl } from "../api/chats"; +import { attachFile, editMessage, requestUploadUrl, sendMediaGroupWithClientId, sendMessageWithClientId, uploadToPresignedUrl } from "../api/chats"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; import { buildWsUrl } from "../utils/ws"; @@ -937,17 +937,18 @@ export function MessageComposer() { clientMessageId, }); const replyToMessageId = replyToByChat[activeChatId]?.id ?? undefined; - const created = await sendMessageWithClientId( + const created = await sendMediaGroupWithClientId( activeChatId, + uploaded.map((item) => ({ + file_url: item.fileUrl, + file_type: item.fileType, + file_size: item.fileSize, + })), caption, - messageType, clientMessageId, replyToMessageId ); - confirmMessageByClientId(activeChatId, clientMessageId, created); - for (const item of uploaded) { - await attachFile(created.id, item.fileUrl, item.fileType, item.fileSize); - } + confirmMessageByClientId(activeChatId, clientMessageId, created.message); setReplyToMessage(activeChatId, null); } catch { setUploadError("Upload failed. Please try again.");