Add MAX contacts and direct chat creation
This commit is contained in:
@@ -23,6 +23,8 @@ data/
|
|||||||
!server/QMax.Api/Data/
|
!server/QMax.Api/Data/
|
||||||
!server/QMax.Api/Data/**
|
!server/QMax.Api/Data/**
|
||||||
storage/
|
storage/
|
||||||
|
!server/QMax.Api/Infrastructure/Storage/
|
||||||
|
!server/QMax.Api/Infrastructure/Storage/**
|
||||||
secrets/
|
secrets/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ android {
|
|||||||
applicationId = "xyz.kusoft.qmax"
|
applicationId = "xyz.kusoft.qmax"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 51
|
versionCode = 52
|
||||||
versionName = "0.1.50"
|
versionName = "0.1.51"
|
||||||
|
|
||||||
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
|
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
|
||||||
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
|
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import xyz.kusoft.qmax.core.push.ForegroundChatTracker
|
import xyz.kusoft.qmax.core.push.ForegroundChatTracker
|
||||||
import xyz.kusoft.qmax.core.model.AttachmentDto
|
import xyz.kusoft.qmax.core.model.AttachmentDto
|
||||||
import xyz.kusoft.qmax.core.model.ChatDto
|
import xyz.kusoft.qmax.core.model.ChatDto
|
||||||
|
import xyz.kusoft.qmax.core.model.ContactDto
|
||||||
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
||||||
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
|
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
|
||||||
import xyz.kusoft.qmax.core.model.MessageDto
|
import xyz.kusoft.qmax.core.model.MessageDto
|
||||||
@@ -380,6 +381,17 @@ private fun ChatListScreen(vm: QMaxViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val filteredContacts = remember(state.contacts, state.searchQuery) {
|
||||||
|
val query = state.searchQuery.trim()
|
||||||
|
if (query.isBlank()) {
|
||||||
|
state.contacts
|
||||||
|
} else {
|
||||||
|
state.contacts.filter { contact ->
|
||||||
|
contact.displayName.contains(query, ignoreCase = true) ||
|
||||||
|
(contact.phoneNumber?.contains(query, ignoreCase = true) == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(activeTab, state.searchQuery) {
|
LaunchedEffect(activeTab, state.searchQuery) {
|
||||||
if (activeTab == MainTab.Channels) {
|
if (activeTab == MainTab.Channels) {
|
||||||
@@ -392,10 +404,14 @@ private fun ChatListScreen(vm: QMaxViewModel) {
|
|||||||
TelegramChatListContent(
|
TelegramChatListContent(
|
||||||
state = state,
|
state = state,
|
||||||
filteredChats = filteredChats,
|
filteredChats = filteredChats,
|
||||||
|
filteredContacts = filteredContacts,
|
||||||
activeTab = activeTab,
|
activeTab = activeTab,
|
||||||
menuOpen = menuOpen,
|
menuOpen = menuOpen,
|
||||||
onMenuOpenChange = { menuOpen = it },
|
onMenuOpenChange = { menuOpen = it },
|
||||||
onRefresh = vm::loadChats,
|
onRefresh = {
|
||||||
|
vm.loadChats()
|
||||||
|
vm.loadContacts()
|
||||||
|
},
|
||||||
onCheckUpdates = vm::checkArgusUpdate,
|
onCheckUpdates = vm::checkArgusUpdate,
|
||||||
onRefreshMaxStatus = vm::loadMaxStatus,
|
onRefreshMaxStatus = vm::loadMaxStatus,
|
||||||
onStartMaxLogin = vm::startMaxLogin,
|
onStartMaxLogin = vm::startMaxLogin,
|
||||||
@@ -410,7 +426,11 @@ private fun ChatListScreen(vm: QMaxViewModel) {
|
|||||||
vm.clearChatSelection()
|
vm.clearChatSelection()
|
||||||
},
|
},
|
||||||
onOpenChat = vm::openChat,
|
onOpenChat = vm::openChat,
|
||||||
onNewChat = { newChatOpen = true },
|
onOpenContact = vm::openContact,
|
||||||
|
onNewChat = {
|
||||||
|
activeTab = MainTab.Contacts
|
||||||
|
vm.updateSearchQuery("")
|
||||||
|
},
|
||||||
onCacheAvatar = vm::cacheAvatar,
|
onCacheAvatar = vm::cacheAvatar,
|
||||||
selectionMenuOpen = selectionMenuOpen,
|
selectionMenuOpen = selectionMenuOpen,
|
||||||
onSelectionMenuOpenChange = { selectionMenuOpen = it },
|
onSelectionMenuOpenChange = { selectionMenuOpen = it },
|
||||||
@@ -623,6 +643,7 @@ private fun ChatListScreen(vm: QMaxViewModel) {
|
|||||||
private fun TelegramChatListContent(
|
private fun TelegramChatListContent(
|
||||||
state: QMaxUiState,
|
state: QMaxUiState,
|
||||||
filteredChats: List<ChatDto>,
|
filteredChats: List<ChatDto>,
|
||||||
|
filteredContacts: List<ContactDto>,
|
||||||
activeTab: MainTab,
|
activeTab: MainTab,
|
||||||
menuOpen: Boolean,
|
menuOpen: Boolean,
|
||||||
onMenuOpenChange: (Boolean) -> Unit,
|
onMenuOpenChange: (Boolean) -> Unit,
|
||||||
@@ -637,6 +658,7 @@ private fun TelegramChatListContent(
|
|||||||
onSubscribeChannel: (MaxChannelSearchResultDto) -> Unit,
|
onSubscribeChannel: (MaxChannelSearchResultDto) -> Unit,
|
||||||
onTabSelected: (MainTab) -> Unit,
|
onTabSelected: (MainTab) -> Unit,
|
||||||
onOpenChat: (ChatDto) -> Unit,
|
onOpenChat: (ChatDto) -> Unit,
|
||||||
|
onOpenContact: (ContactDto) -> Unit,
|
||||||
onNewChat: () -> Unit,
|
onNewChat: () -> Unit,
|
||||||
onCacheAvatar: (String) -> Unit,
|
onCacheAvatar: (String) -> Unit,
|
||||||
selectionMenuOpen: Boolean,
|
selectionMenuOpen: Boolean,
|
||||||
@@ -659,7 +681,7 @@ private fun TelegramChatListContent(
|
|||||||
}
|
}
|
||||||
val emptyText = when (activeTab) {
|
val emptyText = when (activeTab) {
|
||||||
MainTab.Chats -> null
|
MainTab.Chats -> null
|
||||||
MainTab.Contacts -> "Контактов пока нет"
|
MainTab.Contacts -> if (state.contactsLoading) "Загрузка контактов..." else "Контактов пока нет"
|
||||||
MainTab.Channels -> "Каналов пока нет"
|
MainTab.Channels -> "Каналов пока нет"
|
||||||
MainTab.Settings -> null
|
MainTab.Settings -> null
|
||||||
}
|
}
|
||||||
@@ -687,7 +709,7 @@ private fun TelegramChatListContent(
|
|||||||
onLogout = onLogout
|
onLogout = onLogout
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ErrorLine(state.error)
|
ErrorLine(if (activeTab == MainTab.Contacts) state.contactsError ?: state.error else state.error)
|
||||||
if (activeTab != MainTab.Settings && state.maxStatus?.isAuthorized == false) {
|
if (activeTab != MainTab.Settings && state.maxStatus?.isAuthorized == false) {
|
||||||
MaxSessionExpiredBanner(onClick = { onTabSelected(MainTab.Settings) })
|
MaxSessionExpiredBanner(onClick = { onTabSelected(MainTab.Settings) })
|
||||||
}
|
}
|
||||||
@@ -705,11 +727,16 @@ private fun TelegramChatListContent(
|
|||||||
} else {
|
} else {
|
||||||
ChatListSearchBar(
|
ChatListSearchBar(
|
||||||
query = state.searchQuery,
|
query = state.searchQuery,
|
||||||
placeholder = if (activeTab == MainTab.Channels) "Поиск каналов" else "Поиск чатов",
|
placeholder = when (activeTab) {
|
||||||
|
MainTab.Channels -> "Поиск каналов"
|
||||||
|
MainTab.Contacts -> "Поиск контактов"
|
||||||
|
else -> "Поиск чатов"
|
||||||
|
},
|
||||||
onQuery = onSearch
|
onQuery = onSearch
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (listTab && filteredChats.isEmpty() && !showChannelSearch) {
|
val visibleListEmpty = if (activeTab == MainTab.Contacts) filteredContacts.isEmpty() else filteredChats.isEmpty()
|
||||||
|
if (listTab && visibleListEmpty && !showChannelSearch) {
|
||||||
if (state.searchQuery.isNotBlank()) {
|
if (state.searchQuery.isNotBlank()) {
|
||||||
EmptyState("Ничего не найдено")
|
EmptyState("Ничего не найдено")
|
||||||
} else {
|
} else {
|
||||||
@@ -728,24 +755,36 @@ private fun TelegramChatListContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(filteredChats, key = { it.id }) { chat ->
|
if (activeTab == MainTab.Contacts) {
|
||||||
TelegramChatRow(
|
items(filteredContacts, key = { it.userId }) { contact ->
|
||||||
chat = chat,
|
ContactRow(
|
||||||
session = state.session,
|
contact = contact,
|
||||||
cachedAvatarPaths = state.cachedAvatarPaths,
|
session = state.session,
|
||||||
draftText = state.drafts[chat.id],
|
cachedAvatarPaths = state.cachedAvatarPaths,
|
||||||
onCacheAvatar = onCacheAvatar,
|
onCacheAvatar = onCacheAvatar,
|
||||||
isSelected = chat.id in state.selectedChatIds,
|
onClick = { onOpenContact(contact) }
|
||||||
selectionActive = selectedCount > 0,
|
)
|
||||||
onLongClick = { onBeginChatSelection(chat.id) },
|
}
|
||||||
onClick = {
|
} else {
|
||||||
if (selectedCount > 0) {
|
items(filteredChats, key = { it.id }) { chat ->
|
||||||
onToggleChatSelection(chat.id)
|
TelegramChatRow(
|
||||||
} else {
|
chat = chat,
|
||||||
onOpenChat(chat)
|
session = state.session,
|
||||||
|
cachedAvatarPaths = state.cachedAvatarPaths,
|
||||||
|
draftText = state.drafts[chat.id],
|
||||||
|
onCacheAvatar = onCacheAvatar,
|
||||||
|
isSelected = chat.id in state.selectedChatIds,
|
||||||
|
selectionActive = selectedCount > 0,
|
||||||
|
onLongClick = { onBeginChatSelection(chat.id) },
|
||||||
|
onClick = {
|
||||||
|
if (selectedCount > 0) {
|
||||||
|
onToggleChatSelection(chat.id)
|
||||||
|
} else {
|
||||||
|
onOpenChat(chat)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -959,6 +998,55 @@ private fun ChatListSearchBar(query: String, placeholder: String, onQuery: (Stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactRow(
|
||||||
|
contact: ContactDto,
|
||||||
|
session: QMaxSession?,
|
||||||
|
cachedAvatarPaths: Map<String, String>,
|
||||||
|
onCacheAvatar: (String) -> Unit,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val subtitle = contact.status?.takeIf { it.isNotBlank() }
|
||||||
|
?: contact.phoneNumber?.takeIf { it.isNotBlank() }
|
||||||
|
?: "Нажмите, чтобы открыть чат"
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 26.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Avatar(
|
||||||
|
title = contact.displayName,
|
||||||
|
avatarUrl = contact.avatarUrl,
|
||||||
|
session = session,
|
||||||
|
size = 58.dp,
|
||||||
|
cachedAvatarPaths = cachedAvatarPaths,
|
||||||
|
onCacheAvatar = onCacheAvatar
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
contact.displayName,
|
||||||
|
color = QMaxText,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
color = QMaxMuted,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(Icons.Filled.Chat, contentDescription = "Открыть чат", tint = QMaxBlue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun TelegramChatRow(
|
private fun TelegramChatRow(
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import xyz.kusoft.qmax.core.model.AttachmentDto
|
|||||||
import xyz.kusoft.qmax.core.model.AuthResponse
|
import xyz.kusoft.qmax.core.model.AuthResponse
|
||||||
import xyz.kusoft.qmax.core.model.ChatDto
|
import xyz.kusoft.qmax.core.model.ChatDto
|
||||||
import xyz.kusoft.qmax.core.model.ChatPresenceDto
|
import xyz.kusoft.qmax.core.model.ChatPresenceDto
|
||||||
|
import xyz.kusoft.qmax.core.model.ContactDto
|
||||||
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
||||||
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
|
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
|
||||||
import xyz.kusoft.qmax.core.model.MessageDto
|
import xyz.kusoft.qmax.core.model.MessageDto
|
||||||
@@ -91,8 +92,14 @@ class QMaxRepository(
|
|||||||
return chats
|
return chats
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createChat(session: QMaxSession, externalId: String, title: String): ChatDto {
|
suspend fun contacts(session: QMaxSession): List<ContactDto> {
|
||||||
val chat = withFreshSession(session) { api.createDirect(it.serverUrl, it.accessToken, externalId, title) }
|
return withFreshSession(session) { api.contacts(it.serverUrl, it.accessToken) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createChat(session: QMaxSession, externalId: String, title: String, avatarUrl: String? = null): ChatDto {
|
||||||
|
val chat = withFreshSession(session) {
|
||||||
|
api.createDirect(it.serverUrl, it.accessToken, externalId, title, avatarUrl)
|
||||||
|
}
|
||||||
val updatedChats = (cachedChats(session).filterNot { it.id == chat.id } + chat)
|
val updatedChats = (cachedChats(session).filterNot { it.id == chat.id } + chat)
|
||||||
.let(::orderedChats)
|
.let(::orderedChats)
|
||||||
messageCache.saveChats(session, updatedChats)
|
messageCache.saveChats(session, updatedChats)
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ data class ChatPresenceDto(
|
|||||||
val updatedAt: String? = null
|
val updatedAt: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ContactDto(
|
||||||
|
val userId: String,
|
||||||
|
val externalChatId: String,
|
||||||
|
val displayName: String,
|
||||||
|
val avatarUrl: String? = null,
|
||||||
|
val phoneNumber: String? = null,
|
||||||
|
val status: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MessageDto(
|
data class MessageDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import xyz.kusoft.qmax.core.model.ChatBulkActionRequest
|
|||||||
import xyz.kusoft.qmax.core.model.ChatDto
|
import xyz.kusoft.qmax.core.model.ChatDto
|
||||||
import xyz.kusoft.qmax.core.model.ChatPresenceDto
|
import xyz.kusoft.qmax.core.model.ChatPresenceDto
|
||||||
import xyz.kusoft.qmax.core.model.CreateDirectChatRequest
|
import xyz.kusoft.qmax.core.model.CreateDirectChatRequest
|
||||||
|
import xyz.kusoft.qmax.core.model.ContactDto
|
||||||
import xyz.kusoft.qmax.core.model.DeviceLoginRequest
|
import xyz.kusoft.qmax.core.model.DeviceLoginRequest
|
||||||
import xyz.kusoft.qmax.core.model.EditMessageRequest
|
import xyz.kusoft.qmax.core.model.EditMessageRequest
|
||||||
import xyz.kusoft.qmax.core.model.ForwardMessageRequest
|
import xyz.kusoft.qmax.core.model.ForwardMessageRequest
|
||||||
@@ -77,8 +78,18 @@ class QMaxApi {
|
|||||||
return get(sessionServerUrl, "/api/chats", token)
|
return get(sessionServerUrl, "/api/chats", token)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createDirect(sessionServerUrl: String, token: String, externalChatId: String, title: String): ChatDto {
|
suspend fun contacts(sessionServerUrl: String, token: String): List<ContactDto> {
|
||||||
return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title))
|
return get(sessionServerUrl, "/api/contacts", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createDirect(
|
||||||
|
sessionServerUrl: String,
|
||||||
|
token: String,
|
||||||
|
externalChatId: String,
|
||||||
|
title: String,
|
||||||
|
avatarUrl: String? = null
|
||||||
|
): ChatDto {
|
||||||
|
return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title, avatarUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun messages(sessionServerUrl: String, token: String, chatId: String, sync: Boolean = false): List<MessageDto> {
|
suspend fun messages(sessionServerUrl: String, token: String, chatId: String, sync: Boolean = false): List<MessageDto> {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import xyz.kusoft.qmax.BuildConfig
|
|||||||
import xyz.kusoft.qmax.core.QMaxRepository
|
import xyz.kusoft.qmax.core.QMaxRepository
|
||||||
import xyz.kusoft.qmax.core.model.AttachmentDto
|
import xyz.kusoft.qmax.core.model.AttachmentDto
|
||||||
import xyz.kusoft.qmax.core.model.ChatDto
|
import xyz.kusoft.qmax.core.model.ChatDto
|
||||||
|
import xyz.kusoft.qmax.core.model.ContactDto
|
||||||
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
||||||
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
|
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
|
||||||
import xyz.kusoft.qmax.core.model.MessageDeletedDto
|
import xyz.kusoft.qmax.core.model.MessageDeletedDto
|
||||||
@@ -32,6 +33,9 @@ data class QMaxUiState(
|
|||||||
val pairingCode: String = BuildConfig.QMAX_DEFAULT_PAIRING_CODE,
|
val pairingCode: String = BuildConfig.QMAX_DEFAULT_PAIRING_CODE,
|
||||||
val session: QMaxSession? = null,
|
val session: QMaxSession? = null,
|
||||||
val chats: List<ChatDto> = emptyList(),
|
val chats: List<ChatDto> = emptyList(),
|
||||||
|
val contacts: List<ContactDto> = emptyList(),
|
||||||
|
val contactsLoading: Boolean = false,
|
||||||
|
val contactsError: String? = null,
|
||||||
val selectedChat: ChatDto? = null,
|
val selectedChat: ChatDto? = null,
|
||||||
val messages: List<MessageDto> = emptyList(),
|
val messages: List<MessageDto> = emptyList(),
|
||||||
val chatSearchResults: List<MessageDto> = emptyList(),
|
val chatSearchResults: List<MessageDto> = emptyList(),
|
||||||
@@ -105,12 +109,16 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
serverUrl = session?.serverUrl ?: state.value.serverUrl,
|
serverUrl = session?.serverUrl ?: state.value.serverUrl,
|
||||||
cachedImagePaths = if (sessionChanged) emptyMap() else state.value.cachedImagePaths,
|
cachedImagePaths = if (sessionChanged) emptyMap() else state.value.cachedImagePaths,
|
||||||
cachedAvatarPaths = if (sessionChanged) emptyMap() else state.value.cachedAvatarPaths,
|
cachedAvatarPaths = if (sessionChanged) emptyMap() else state.value.cachedAvatarPaths,
|
||||||
|
contacts = if (sessionChanged) emptyList() else state.value.contacts,
|
||||||
|
contactsLoading = if (sessionChanged) false else state.value.contactsLoading,
|
||||||
|
contactsError = if (sessionChanged) null else state.value.contactsError,
|
||||||
selectedChatIds = if (sessionChanged) emptySet() else state.value.selectedChatIds
|
selectedChatIds = if (sessionChanged) emptySet() else state.value.selectedChatIds
|
||||||
)
|
)
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
autoLoginJob?.cancel()
|
autoLoginJob?.cancel()
|
||||||
restoreCachedChats(session)
|
restoreCachedChats(session)
|
||||||
loadChats()
|
loadChats()
|
||||||
|
loadContacts()
|
||||||
connectRealtime(session)
|
connectRealtime(session)
|
||||||
registerPushDevice(session)
|
registerPushDevice(session)
|
||||||
startMaxStatusPolling(session)
|
startMaxStatusPolling(session)
|
||||||
@@ -266,6 +274,36 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
|
|
||||||
fun loadChats() = refreshChats()
|
fun loadChats() = refreshChats()
|
||||||
|
|
||||||
|
fun loadContacts() {
|
||||||
|
val session = state.value.session ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
state.value = state.value.copy(contactsLoading = true, contactsError = null)
|
||||||
|
runCatching { repository.contacts(session) }
|
||||||
|
.onSuccess { contacts ->
|
||||||
|
if (state.value.session?.serverUrl == session.serverUrl &&
|
||||||
|
state.value.session?.userName == session.userName
|
||||||
|
) {
|
||||||
|
state.value = state.value.copy(
|
||||||
|
contacts = contacts,
|
||||||
|
contactsLoading = false,
|
||||||
|
contactsError = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
if (state.value.session?.serverUrl == session.serverUrl &&
|
||||||
|
state.value.session?.userName == session.userName
|
||||||
|
) {
|
||||||
|
state.value = state.value.copy(
|
||||||
|
contactsLoading = false,
|
||||||
|
contactsError = error.message ?: "Не удалось загрузить контакты"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun restoreCachedChats(session: QMaxSession) {
|
private suspend fun restoreCachedChats(session: QMaxSession) {
|
||||||
runCatching {
|
runCatching {
|
||||||
repository.cachedChats(session)
|
repository.cachedChats(session)
|
||||||
@@ -366,6 +404,22 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
loadChats()
|
loadChats()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openContact(contact: ContactDto) = launchLoading(false) {
|
||||||
|
val session = requireSession()
|
||||||
|
val existing = state.value.chats.firstOrNull { it.externalId == contact.externalChatId }
|
||||||
|
val chat = existing ?: repository.createChat(
|
||||||
|
session,
|
||||||
|
contact.externalChatId,
|
||||||
|
contact.displayName,
|
||||||
|
contact.avatarUrl
|
||||||
|
)
|
||||||
|
if (existing == null) {
|
||||||
|
state.value = state.value.copy(chats = normalizeChats(state.value.chats + chat))
|
||||||
|
}
|
||||||
|
openChat(chat)
|
||||||
|
loadChats()
|
||||||
|
}
|
||||||
|
|
||||||
fun openChat(chat: ChatDto) {
|
fun openChat(chat: ChatDto) {
|
||||||
saveCurrentDraft()
|
saveCurrentDraft()
|
||||||
chatSearchJob?.cancel()
|
chatSearchJob?.cancel()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from typing import Any
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from pymax import Client, File, Photo, Video
|
from pymax import Client, ExtraConfig, File, Photo, SyncOverrides, Video
|
||||||
|
|
||||||
|
|
||||||
PORT = int(os.environ.get("PORT", "3002"))
|
PORT = int(os.environ.get("PORT", "3002"))
|
||||||
@@ -253,6 +253,21 @@ def user_avatar_url(user: Any) -> str | None:
|
|||||||
return first_text(data.get("base_url"), data.get("base_raw_url"))
|
return first_text(data.get("base_url"), data.get("base_raw_url"))
|
||||||
|
|
||||||
|
|
||||||
|
def contact_result(contact: Any, me_id: int, external_chat_id: Any | None = None) -> dict[str, Any] | None:
|
||||||
|
data = dump_model(contact)
|
||||||
|
contact_id = coerce_int(data.get("id") or getattr(contact, "id", None))
|
||||||
|
if contact_id is None or contact_id <= 0 or contact_id == me_id:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"userId": clean_id(contact_id),
|
||||||
|
"externalChatId": clean_id(external_chat_id if external_chat_id is not None else contact_id ^ me_id),
|
||||||
|
"displayName": user_display_name(contact) or f"MAX {contact_id}",
|
||||||
|
"avatarUrl": user_avatar_url(contact),
|
||||||
|
"phoneNumber": first_text(data.get("phone")),
|
||||||
|
"status": first_text(data.get("status"), data.get("description")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def chat_other_user(chat: Any, user_map: dict[str, Any], me_id: int | None) -> Any | None:
|
def chat_other_user(chat: Any, user_map: dict[str, Any], me_id: int | None) -> Any | None:
|
||||||
for user_id in chat_participant_ids(chat):
|
for user_id in chat_participant_ids(chat):
|
||||||
if me_id is not None and user_id == me_id:
|
if me_id is not None and user_id == me_id:
|
||||||
@@ -391,6 +406,9 @@ class PyMaxRuntime:
|
|||||||
session_name=SESSION_NAME,
|
session_name=SESSION_NAME,
|
||||||
sms_code_provider=self.code_provider,
|
sms_code_provider=self.code_provider,
|
||||||
password_provider=self.password_provider,
|
password_provider=self.password_provider,
|
||||||
|
extra_config=ExtraConfig(
|
||||||
|
sync=SyncOverrides(chats_sync=-1, contacts_sync=-1),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@client.on_start()
|
@client.on_start()
|
||||||
@@ -505,6 +523,10 @@ def normalize_chat_kind(chat: Any) -> str:
|
|||||||
return "MaxDialog"
|
return "MaxDialog"
|
||||||
|
|
||||||
|
|
||||||
|
def is_direct_dialog(chat: Any) -> bool:
|
||||||
|
return value_name(getattr(chat, "type", "")).upper() == "DIALOG"
|
||||||
|
|
||||||
|
|
||||||
def normalize_attachment_kind(att: Any) -> str:
|
def normalize_attachment_kind(att: Any) -> str:
|
||||||
data = dump_model(att)
|
data = dump_model(att)
|
||||||
raw_type = value_name(data.get("type") or data.get("_type") or getattr(att, "type", "")).lower()
|
raw_type = value_name(data.get("type") or data.get("_type") or getattr(att, "type", "")).lower()
|
||||||
@@ -729,19 +751,35 @@ async def normalize_chat_update(
|
|||||||
|
|
||||||
async def fetch_chat_pages(client: Client, limit: int) -> list[Any]:
|
async def fetch_chat_pages(client: Client, limit: int) -> list[Any]:
|
||||||
chats: list[Any] = []
|
chats: list[Any] = []
|
||||||
|
seen_chat_ids: set[str] = set()
|
||||||
|
seen_markers: set[int] = set()
|
||||||
marker: int | None = None
|
marker: int | None = None
|
||||||
while len(chats) < limit:
|
while len(chats) < limit:
|
||||||
page = await client.fetch_chats(marker=marker)
|
page = await client.fetch_chats(marker=marker)
|
||||||
page = page or []
|
page = page or []
|
||||||
if not page:
|
if not page:
|
||||||
break
|
break
|
||||||
chats.extend(page)
|
|
||||||
|
added = 0
|
||||||
|
for chat in page:
|
||||||
|
chat_id = clean_id(getattr(chat, "id", None))
|
||||||
|
if not chat_id or chat_id in seen_chat_ids:
|
||||||
|
continue
|
||||||
|
seen_chat_ids.add(chat_id)
|
||||||
|
chats.append(chat)
|
||||||
|
added += 1
|
||||||
|
if len(chats) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
times = [getattr(chat, "last_event_time", None) for chat in page if getattr(chat, "last_event_time", None)]
|
times = [getattr(chat, "last_event_time", None) for chat in page if getattr(chat, "last_event_time", None)]
|
||||||
if not times:
|
if not times or added == 0:
|
||||||
break
|
break
|
||||||
marker = min(times) - 1
|
|
||||||
if len(page) < 50:
|
next_marker = min(times) - 1
|
||||||
|
if next_marker in seen_markers or (marker is not None and next_marker >= marker):
|
||||||
break
|
break
|
||||||
|
seen_markers.add(next_marker)
|
||||||
|
marker = next_marker
|
||||||
return chats[:limit]
|
return chats[:limit]
|
||||||
|
|
||||||
|
|
||||||
@@ -891,6 +929,33 @@ async def updates(_request: web.Request) -> web.Response:
|
|||||||
return json_response(result)
|
return json_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@route_errors
|
||||||
|
async def contacts(_request: web.Request) -> web.Response:
|
||||||
|
client = await runtime.get_client()
|
||||||
|
me_id = get_me_user_id(client)
|
||||||
|
if me_id is None:
|
||||||
|
return json_response({"success": False, "error": "MAX profile has no user id."}, status=503)
|
||||||
|
|
||||||
|
by_user_id: dict[str, dict[str, Any]] = {}
|
||||||
|
for contact in client.contacts:
|
||||||
|
normalized = contact_result(contact, me_id) if contact is not None else None
|
||||||
|
if normalized is not None:
|
||||||
|
by_user_id[normalized["userId"]] = normalized
|
||||||
|
|
||||||
|
chats = await fetch_chat_pages(client, CHAT_FETCH_LIMIT)
|
||||||
|
direct_chats = [chat for chat in chats if is_direct_dialog(chat)]
|
||||||
|
user_map = await build_user_map(client, direct_chats)
|
||||||
|
for chat in direct_chats:
|
||||||
|
contact = chat_other_user(chat, user_map, me_id)
|
||||||
|
normalized = contact_result(contact, me_id, getattr(chat, "id", None)) if contact is not None else None
|
||||||
|
if normalized is not None:
|
||||||
|
by_user_id.setdefault(normalized["userId"], normalized)
|
||||||
|
|
||||||
|
result = list(by_user_id.values())
|
||||||
|
result.sort(key=lambda item: item["displayName"].casefold())
|
||||||
|
return json_response(result)
|
||||||
|
|
||||||
|
|
||||||
@route_errors
|
@route_errors
|
||||||
async def chat_history(request: web.Request) -> web.Response:
|
async def chat_history(request: web.Request) -> web.Response:
|
||||||
data = await read_json(request)
|
data = await read_json(request)
|
||||||
@@ -1017,6 +1082,7 @@ def create_app() -> web.Application:
|
|||||||
app.router.add_post("/login/code", login_code)
|
app.router.add_post("/login/code", login_code)
|
||||||
app.router.add_get("/snapshot", snapshot)
|
app.router.add_get("/snapshot", snapshot)
|
||||||
app.router.add_get("/updates", updates)
|
app.router.add_get("/updates", updates)
|
||||||
|
app.router.add_get("/contacts", contacts)
|
||||||
app.router.add_post("/chat/history", chat_history)
|
app.router.add_post("/chat/history", chat_history)
|
||||||
app.router.add_post("/chat/resolve-url", resolve_chat_url)
|
app.router.add_post("/chat/resolve-url", resolve_chat_url)
|
||||||
app.router.add_post("/channels/search", channels_search)
|
app.router.add_post("/channels/search", channels_search)
|
||||||
|
|||||||
@@ -52,3 +52,11 @@ public sealed record CreateDirectChatRequest(string ExternalChatId, string Title
|
|||||||
public sealed record ChatBulkActionRequest(IReadOnlyList<Guid> ChatIds);
|
public sealed record ChatBulkActionRequest(IReadOnlyList<Guid> ChatIds);
|
||||||
|
|
||||||
public sealed record ChatPresenceDto(bool IsTyping, string? StatusText, DateTimeOffset UpdatedAt);
|
public sealed record ChatPresenceDto(bool IsTyping, string? StatusText, DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
|
public sealed record ContactDto(
|
||||||
|
string UserId,
|
||||||
|
string ExternalChatId,
|
||||||
|
string DisplayName,
|
||||||
|
string? AvatarUrl,
|
||||||
|
string? PhoneNumber,
|
||||||
|
string? Status);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using QMax.Api.Contracts;
|
||||||
|
using QMax.Api.Infrastructure.Max;
|
||||||
|
|
||||||
|
namespace QMax.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/contacts")]
|
||||||
|
public sealed class ContactsController(IMaxBridgeClient maxBridge) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<ContactDto>>> GetContacts(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var contacts = await maxBridge.FetchContactsAsync(cancellationToken);
|
||||||
|
return contacts
|
||||||
|
.Select(contact => new ContactDto(
|
||||||
|
contact.UserId,
|
||||||
|
contact.ExternalChatId,
|
||||||
|
contact.DisplayName,
|
||||||
|
contact.AvatarUrl,
|
||||||
|
contact.PhoneNumber,
|
||||||
|
contact.Status))
|
||||||
|
.OrderBy(contact => contact.DisplayName, StringComparer.CurrentCultureIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,10 @@ public interface IMaxBridgeClient
|
|||||||
Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken);
|
Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken);
|
||||||
Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken);
|
Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken);
|
||||||
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
|
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IReadOnlyList<MaxContact>>(Array.Empty<MaxContact>());
|
||||||
|
}
|
||||||
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
|
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
|
||||||
Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
|
Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
|
||||||
Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken);
|
Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -73,3 +73,11 @@ public sealed record MaxChannelSearchResult(
|
|||||||
string? ChatUrl,
|
string? ChatUrl,
|
||||||
bool IsSubscribed,
|
bool IsSubscribed,
|
||||||
string? Description = null);
|
string? Description = null);
|
||||||
|
|
||||||
|
public sealed record MaxContact(
|
||||||
|
string UserId,
|
||||||
|
string ExternalChatId,
|
||||||
|
string DisplayName,
|
||||||
|
string? AvatarUrl,
|
||||||
|
string? PhoneNumber,
|
||||||
|
string? Status);
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
|
|||||||
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates);
|
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IReadOnlyList<MaxContact>>([
|
||||||
|
new MaxContact("mock-user", "mock-direct", "Mock contact", null, "+70000000000", "online")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId);
|
var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId);
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ public sealed class WorkerMaxBridgeClient(
|
|||||||
?? throw new InvalidOperationException("MAX worker returned an empty updates response.");
|
?? throw new InvalidOperationException("MAX worker returned an empty updates response.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await SendAsync<IReadOnlyList<MaxContact>>(HttpMethod.Get, "/contacts", null, cancellationToken)
|
||||||
|
?? Array.Empty<MaxContact>();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await SendAsync<MaxChatUpdate>(HttpMethod.Post, "/chat/history", new { externalChatId, chatUrl }, cancellationToken);
|
return await SendAsync<MaxChatUpdate>(HttpMethod.Post, "/chat/history", new { externalChatId, chatUrl }, cancellationToken);
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using QMax.Api.Configuration;
|
||||||
|
using QMax.Api.Data.Entities;
|
||||||
|
|
||||||
|
namespace QMax.Api.Infrastructure.Storage;
|
||||||
|
|
||||||
|
public sealed record StoredAttachment(
|
||||||
|
string OriginalFileName,
|
||||||
|
string StorageFileName,
|
||||||
|
string ContentType,
|
||||||
|
long FileSizeBytes,
|
||||||
|
string Sha256,
|
||||||
|
AttachmentKind Kind);
|
||||||
|
|
||||||
|
public interface IAttachmentStorageService
|
||||||
|
{
|
||||||
|
Task<StoredAttachment> SaveAsync(IFormFile file, CancellationToken cancellationToken);
|
||||||
|
Task<StoredAttachment> SaveRemoteAsync(
|
||||||
|
string fileName,
|
||||||
|
string? contentType,
|
||||||
|
Stream stream,
|
||||||
|
long? expectedLength,
|
||||||
|
AttachmentKind? preferredKind,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
string GetPath(string storageFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AttachmentStorageService(IOptions<QMaxOptions> options) : IAttachmentStorageService
|
||||||
|
{
|
||||||
|
private readonly QMaxOptions _options = options.Value;
|
||||||
|
|
||||||
|
public async Task<StoredAttachment> SaveAsync(IFormFile file, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (file.Length <= 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Empty files are not allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.Length > _options.MaxUploadBytes)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_options.StoragePath);
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(file.FileName);
|
||||||
|
if (extension.Length > 16)
|
||||||
|
{
|
||||||
|
extension = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var storageName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}{extension.ToLowerInvariant()}";
|
||||||
|
var finalPath = GetPath(storageName);
|
||||||
|
var partPath = finalPath + ".part";
|
||||||
|
|
||||||
|
await using (var target = new FileStream(partPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 128 * 1024, true))
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(target, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = new FileInfo(partPath);
|
||||||
|
if (info.Length != file.Length)
|
||||||
|
{
|
||||||
|
File.Delete(partPath);
|
||||||
|
throw new IOException("Uploaded file size verification failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = await ComputeSha256Async(partPath, cancellationToken);
|
||||||
|
File.Move(partPath, finalPath, false);
|
||||||
|
|
||||||
|
return new StoredAttachment(
|
||||||
|
Path.GetFileName(file.FileName),
|
||||||
|
storageName,
|
||||||
|
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
|
||||||
|
file.Length,
|
||||||
|
hash,
|
||||||
|
GuessKind(file.ContentType, extension));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StoredAttachment> SaveRemoteAsync(
|
||||||
|
string fileName,
|
||||||
|
string? contentType,
|
||||||
|
Stream stream,
|
||||||
|
long? expectedLength,
|
||||||
|
AttachmentKind? preferredKind,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (expectedLength is > 0 && expectedLength > _options.MaxUploadBytes)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_options.StoragePath);
|
||||||
|
|
||||||
|
var safeOriginalName = NormalizeRemoteFileName(fileName, contentType, preferredKind);
|
||||||
|
var extension = Path.GetExtension(safeOriginalName);
|
||||||
|
if (extension.Length > 16)
|
||||||
|
{
|
||||||
|
extension = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var storageName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}{extension.ToLowerInvariant()}";
|
||||||
|
var finalPath = GetPath(storageName);
|
||||||
|
var partPath = finalPath + ".part";
|
||||||
|
|
||||||
|
await using (var target = new FileStream(partPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 128 * 1024, true))
|
||||||
|
{
|
||||||
|
await stream.CopyToAsync(target, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = new FileInfo(partPath);
|
||||||
|
if (info.Length <= 0)
|
||||||
|
{
|
||||||
|
File.Delete(partPath);
|
||||||
|
throw new IOException("Downloaded MAX media is empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedLength is > 0 && info.Length != expectedLength)
|
||||||
|
{
|
||||||
|
File.Delete(partPath);
|
||||||
|
throw new IOException("Downloaded MAX media size verification failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.Length > _options.MaxUploadBytes)
|
||||||
|
{
|
||||||
|
File.Delete(partPath);
|
||||||
|
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
|
||||||
|
var resolvedKind = preferredKind ?? GuessKind(resolvedContentType, extension);
|
||||||
|
var hash = await ComputeSha256Async(partPath, cancellationToken);
|
||||||
|
File.Move(partPath, finalPath, false);
|
||||||
|
|
||||||
|
return new StoredAttachment(
|
||||||
|
safeOriginalName,
|
||||||
|
storageName,
|
||||||
|
resolvedContentType,
|
||||||
|
info.Length,
|
||||||
|
hash,
|
||||||
|
resolvedKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetPath(string storageFileName)
|
||||||
|
{
|
||||||
|
var safeName = Path.GetFileName(storageFileName);
|
||||||
|
return Path.Combine(_options.StoragePath, safeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ComputeSha256Async(string path, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var stream = File.OpenRead(path);
|
||||||
|
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
|
||||||
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AttachmentKind GuessKind(string? contentType, string extension)
|
||||||
|
{
|
||||||
|
var content = contentType?.ToLowerInvariant() ?? "";
|
||||||
|
var ext = extension.ToLowerInvariant();
|
||||||
|
if (content.Contains("vcard", StringComparison.Ordinal) || ext == ".vcf") return AttachmentKind.Contact;
|
||||||
|
if (content.StartsWith("image/gif") || ext == ".gif") return AttachmentKind.Gif;
|
||||||
|
if (content.StartsWith("image/")) return AttachmentKind.Image;
|
||||||
|
if (content.StartsWith("video/")) return AttachmentKind.Video;
|
||||||
|
if (content.StartsWith("audio/") || ext is ".ogg" or ".opus" or ".m4a" or ".aac" or ".mp3" or ".wav" or ".flac") return AttachmentKind.VoiceNote;
|
||||||
|
return AttachmentKind.File;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizeRemoteFileName(string? fileName, string? contentType, AttachmentKind? preferredKind)
|
||||||
|
{
|
||||||
|
var original = string.IsNullOrWhiteSpace(fileName) ? "" : Path.GetFileName(fileName.Trim());
|
||||||
|
var extension = Path.GetExtension(original);
|
||||||
|
if (extension.Length > 16)
|
||||||
|
{
|
||||||
|
extension = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
|
||||||
|
var kind = preferredKind ?? GuessKind(resolvedContentType, extension);
|
||||||
|
var fallbackExtension = string.IsNullOrWhiteSpace(extension)
|
||||||
|
? DefaultExtension(resolvedContentType, kind)
|
||||||
|
: extension.ToLowerInvariant();
|
||||||
|
|
||||||
|
return IsUnhelpfulRemoteFileName(original, kind, extension)
|
||||||
|
? $"max-{KindSlug(kind)}{fallbackExtension}"
|
||||||
|
: original;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsUnhelpfulRemoteFileName(string value, AttachmentKind kind, string extension)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = value.Trim().ToLowerInvariant();
|
||||||
|
var stem = Path.GetFileNameWithoutExtension(normalized).Trim();
|
||||||
|
if (stem is
|
||||||
|
"photo" or
|
||||||
|
"image" or
|
||||||
|
"video" or
|
||||||
|
"audio" or
|
||||||
|
"voice" or
|
||||||
|
"gif" or
|
||||||
|
"media" or
|
||||||
|
"contact" or
|
||||||
|
"\u0444\u043e\u0442\u043e" or
|
||||||
|
"\u0432\u0438\u0434\u0435\u043e" or
|
||||||
|
"\u0430\u0443\u0434\u0438\u043e" or
|
||||||
|
"\u0433\u043e\u043b\u043e\u0441" or
|
||||||
|
"\u043a\u043e\u043d\u0442\u0430\u043a\u0442")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.Contains("\u0431\u0440\u0430\u0443\u0437\u0435\u0440", StringComparison.Ordinal) &&
|
||||||
|
normalized.Contains("\u043d\u0435", StringComparison.Ordinal) &&
|
||||||
|
normalized.Contains("\u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.Contains("your browser", StringComparison.Ordinal) ||
|
||||||
|
normalized.Contains("not supported", StringComparison.Ordinal) ||
|
||||||
|
normalized.Contains("[object object]", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(extension) &&
|
||||||
|
kind is AttachmentKind.Image or AttachmentKind.Gif or AttachmentKind.Video or AttachmentKind.VoiceNote or AttachmentKind.Sticker;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DefaultExtension(string contentType, AttachmentKind kind)
|
||||||
|
{
|
||||||
|
var content = contentType.ToLowerInvariant();
|
||||||
|
if (content.Contains("webp", StringComparison.Ordinal)) return ".webp";
|
||||||
|
if (content.Contains("jpeg", StringComparison.Ordinal) || content.Contains("jpg", StringComparison.Ordinal)) return ".jpg";
|
||||||
|
if (content.Contains("png", StringComparison.Ordinal)) return ".png";
|
||||||
|
if (content.Contains("gif", StringComparison.Ordinal)) return ".gif";
|
||||||
|
if (content.Contains("mp4", StringComparison.Ordinal)) return kind == AttachmentKind.VoiceNote ? ".m4a" : ".mp4";
|
||||||
|
if (content.Contains("webm", StringComparison.Ordinal)) return ".webm";
|
||||||
|
if (content.Contains("ogg", StringComparison.Ordinal) || content.Contains("opus", StringComparison.Ordinal)) return ".ogg";
|
||||||
|
if (content.Contains("mpeg", StringComparison.Ordinal)) return ".mp3";
|
||||||
|
if (content.Contains("wav", StringComparison.Ordinal)) return ".wav";
|
||||||
|
if (content.Contains("pdf", StringComparison.Ordinal)) return ".pdf";
|
||||||
|
if (content.Contains("vcard", StringComparison.Ordinal)) return ".vcf";
|
||||||
|
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
AttachmentKind.Image => ".jpg",
|
||||||
|
AttachmentKind.Gif => ".gif",
|
||||||
|
AttachmentKind.Video => ".mp4",
|
||||||
|
AttachmentKind.VoiceNote => ".ogg",
|
||||||
|
AttachmentKind.Sticker => ".webp",
|
||||||
|
AttachmentKind.Contact => ".vcf",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string KindSlug(AttachmentKind kind)
|
||||||
|
{
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
AttachmentKind.Image => "image",
|
||||||
|
AttachmentKind.Gif => "gif",
|
||||||
|
AttachmentKind.Video => "video",
|
||||||
|
AttachmentKind.VoiceNote => "voice",
|
||||||
|
AttachmentKind.Sticker => "sticker",
|
||||||
|
AttachmentKind.Contact => "contact",
|
||||||
|
AttachmentKind.File => "file",
|
||||||
|
_ => "media"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,6 +115,27 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
Assert.Equal("pymax://mock/mock-direct", storedChat.WebUrl);
|
Assert.Equal("pymax://mock/mock-direct", storedChat.WebUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContactCanCreateDirectChat()
|
||||||
|
{
|
||||||
|
using var client = _factory.CreateClient();
|
||||||
|
await LoginAsync(client);
|
||||||
|
|
||||||
|
var contacts = await client.GetFromJsonAsync<ContactDto[]>("/api/contacts", JsonOptions);
|
||||||
|
var contact = Assert.Single(contacts!);
|
||||||
|
Assert.Equal("mock-direct", contact.ExternalChatId);
|
||||||
|
Assert.Equal("Mock contact", contact.DisplayName);
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
"/api/chats/direct",
|
||||||
|
new CreateDirectChatRequest(contact.ExternalChatId, contact.DisplayName, contact.AvatarUrl));
|
||||||
|
await AssertStatusAsync(HttpStatusCode.OK, response);
|
||||||
|
var chat = await response.Content.ReadFromJsonAsync<ChatDto>(JsonOptions);
|
||||||
|
Assert.NotNull(chat);
|
||||||
|
Assert.Equal(contact.ExternalChatId, chat!.ExternalId);
|
||||||
|
Assert.Equal(contact.DisplayName, chat.Title);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ChatListReturnsAllChatsBeyondLegacyLimit()
|
public async Task ChatListReturnsAllChatsBeyondLegacyLimit()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user