Sync phone contacts and enable chat deletion

This commit is contained in:
Курнат Андрей
2026-07-13 21:10:10 +03:00
parent ff3f139889
commit 582f99ed0e
17 changed files with 636 additions and 49 deletions
+2 -2
View File
@@ -22,8 +22,8 @@ android {
applicationId = "xyz.kusoft.qmax" applicationId = "xyz.kusoft.qmax"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 52 versionCode = 54
versionName = "0.1.51" versionName = "0.1.53"
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\"")
+1
View File
@@ -2,6 +2,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
@@ -2,6 +2,7 @@ package xyz.kusoft.qmax
import android.Manifest import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.ContentResolver
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.media.MediaRecorder import android.media.MediaRecorder
@@ -9,6 +10,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.provider.ContactsContract
import android.view.WindowManager import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
@@ -78,6 +80,7 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
@@ -169,6 +172,7 @@ 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
import xyz.kusoft.qmax.core.model.PhoneContactDto
import xyz.kusoft.qmax.core.model.QMaxSession import xyz.kusoft.qmax.core.model.QMaxSession
import xyz.kusoft.qmax.ui.QMaxUiState import xyz.kusoft.qmax.ui.QMaxUiState
import xyz.kusoft.qmax.ui.QMaxViewModel import xyz.kusoft.qmax.ui.QMaxViewModel
@@ -217,6 +221,39 @@ class MainActivity : ComponentActivity() {
private fun Intent?.chatIdExtra(): String? = this?.getStringExtra("chatId")?.takeIf { it.isNotBlank() } private fun Intent?.chatIdExtra(): String? = this?.getStringExtra("chatId")?.takeIf { it.isNotBlank() }
private fun readPhoneContacts(contentResolver: ContentResolver): List<PhoneContactDto> {
val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NUMBER,
ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER
)
val seenNumbers = mutableSetOf<String>()
val result = mutableListOf<PhoneContactDto>()
contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
projection,
null,
null,
"${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} COLLATE LOCALIZED ASC"
)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
val normalizedIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER)
while (cursor.moveToNext()) {
val rawNumber = (cursor.getString(normalizedIndex)?.takeIf { it.isNotBlank() }
?: cursor.getString(numberIndex)).orEmpty()
val normalizedNumber = rawNumber.filter { it.isDigit() || it == '+' }
if (normalizedNumber.isBlank() || !seenNumbers.add(normalizedNumber)) continue
val displayName = cursor.getString(nameIndex)?.trim().orEmpty().ifBlank { normalizedNumber }
result += PhoneContactDto(
phoneNumber = normalizedNumber,
firstName = displayName
)
}
}
return result
}
@Composable @Composable
private fun QMaxApp(vm: QMaxViewModel) { private fun QMaxApp(vm: QMaxViewModel) {
val state by vm.state val state by vm.state
@@ -344,7 +381,8 @@ private fun LoginScreen(vm: QMaxViewModel) {
} }
private enum class ChatBulkUiAction { private enum class ChatBulkUiAction {
ClearHistory ClearHistory,
DeleteChats
} }
private enum class MainTab { private enum class MainTab {
@@ -362,6 +400,7 @@ private fun ChatListScreen(vm: QMaxViewModel) {
var menuOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) }
var selectionMenuOpen by remember { mutableStateOf(false) } var selectionMenuOpen by remember { mutableStateOf(false) }
var pendingBulkAction by remember { mutableStateOf<ChatBulkUiAction?>(null) } var pendingBulkAction by remember { mutableStateOf<ChatBulkUiAction?>(null) }
var pendingContactRemoval by remember { mutableStateOf<ContactDto?>(null) }
var searchMode by remember { mutableStateOf(false) } var searchMode by remember { mutableStateOf(false) }
var newChatOpen by remember { mutableStateOf(false) } var newChatOpen by remember { mutableStateOf(false) }
val filteredChats = remember(state.chats, state.searchQuery, activeTab) { val filteredChats = remember(state.chats, state.searchQuery, activeTab) {
@@ -392,6 +431,29 @@ private fun ChatListScreen(vm: QMaxViewModel) {
} }
} }
} }
val context = LocalContext.current
val scope = rememberCoroutineScope()
val importPhoneContacts: () -> Unit = {
scope.launch {
val contacts = withContext(Dispatchers.IO) { readPhoneContacts(context.contentResolver) }
vm.importPhoneContacts(contacts)
}
Unit
}
val contactsPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
importPhoneContacts()
} else {
vm.reportContactsPermissionDenied()
}
}
val requestPhoneContactsImport: () -> Unit = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) {
importPhoneContacts()
} else {
contactsPermissionLauncher.launch(Manifest.permission.READ_CONTACTS)
}
}
LaunchedEffect(activeTab, state.searchQuery) { LaunchedEffect(activeTab, state.searchQuery) {
if (activeTab == MainTab.Channels) { if (activeTab == MainTab.Channels) {
@@ -401,6 +463,12 @@ private fun ChatListScreen(vm: QMaxViewModel) {
} }
} }
LaunchedEffect(activeTab, state.session?.userName) {
if (activeTab == MainTab.Contacts && state.session != null) {
requestPhoneContactsImport()
}
}
TelegramChatListContent( TelegramChatListContent(
state = state, state = state,
filteredChats = filteredChats, filteredChats = filteredChats,
@@ -410,7 +478,7 @@ private fun ChatListScreen(vm: QMaxViewModel) {
onMenuOpenChange = { menuOpen = it }, onMenuOpenChange = { menuOpen = it },
onRefresh = { onRefresh = {
vm.loadChats() vm.loadChats()
vm.loadContacts() if (activeTab == MainTab.Contacts) requestPhoneContactsImport()
}, },
onCheckUpdates = vm::checkArgusUpdate, onCheckUpdates = vm::checkArgusUpdate,
onRefreshMaxStatus = vm::loadMaxStatus, onRefreshMaxStatus = vm::loadMaxStatus,
@@ -427,6 +495,9 @@ private fun ChatListScreen(vm: QMaxViewModel) {
}, },
onOpenChat = vm::openChat, onOpenChat = vm::openChat,
onOpenContact = vm::openContact, onOpenContact = vm::openContact,
onImportPhoneContacts = requestPhoneContactsImport,
onAddContact = vm::addContact,
onRemoveContact = { pendingContactRemoval = it },
onNewChat = { onNewChat = {
activeTab = MainTab.Contacts activeTab = MainTab.Contacts
vm.updateSearchQuery("") vm.updateSearchQuery("")
@@ -440,6 +511,10 @@ private fun ChatListScreen(vm: QMaxViewModel) {
onClearSelectedHistory = { onClearSelectedHistory = {
selectionMenuOpen = false selectionMenuOpen = false
pendingBulkAction = ChatBulkUiAction.ClearHistory pendingBulkAction = ChatBulkUiAction.ClearHistory
},
onDeleteSelectedChats = {
selectionMenuOpen = false
pendingBulkAction = ChatBulkUiAction.DeleteChats
} }
) )
@@ -451,6 +526,7 @@ private fun ChatListScreen(vm: QMaxViewModel) {
Text( Text(
when (action) { when (action) {
ChatBulkUiAction.ClearHistory -> "Очистить историю" ChatBulkUiAction.ClearHistory -> "Очистить историю"
ChatBulkUiAction.DeleteChats -> "Удалить чаты"
} }
) )
}, },
@@ -458,6 +534,7 @@ private fun ChatListScreen(vm: QMaxViewModel) {
Text( Text(
when (action) { when (action) {
ChatBulkUiAction.ClearHistory -> "Очистить историю в выбранных чатах: $selectedCount?" ChatBulkUiAction.ClearHistory -> "Очистить историю в выбранных чатах: $selectedCount?"
ChatBulkUiAction.DeleteChats -> "Удалить выбранные чаты из MAX: $selectedCount? Это действие нельзя отменить."
} }
) )
}, },
@@ -467,12 +544,14 @@ private fun ChatListScreen(vm: QMaxViewModel) {
pendingBulkAction = null pendingBulkAction = null
when (action) { when (action) {
ChatBulkUiAction.ClearHistory -> vm.clearSelectedChatHistories() ChatBulkUiAction.ClearHistory -> vm.clearSelectedChatHistories()
ChatBulkUiAction.DeleteChats -> vm.deleteSelectedChats()
} }
} }
) { ) {
Text( Text(
when (action) { when (action) {
ChatBulkUiAction.ClearHistory -> "Очистить" ChatBulkUiAction.ClearHistory -> "Очистить"
ChatBulkUiAction.DeleteChats -> "Удалить"
} }
) )
} }
@@ -485,6 +564,23 @@ private fun ChatListScreen(vm: QMaxViewModel) {
) )
} }
pendingContactRemoval?.let { contact ->
AlertDialog(
onDismissRequest = { pendingContactRemoval = null },
title = { Text("Удалить контакт") },
text = { Text("Удалить ${contact.displayName} из контактов MAX? Чат и сообщения останутся.") },
confirmButton = {
TextButton(onClick = {
pendingContactRemoval = null
vm.removeContact(contact)
}) { Text("Удалить") }
},
dismissButton = {
TextButton(onClick = { pendingContactRemoval = null }) { Text("Отмена") }
}
)
}
if (newChatOpen) { if (newChatOpen) {
NewChatDialog( NewChatDialog(
vm = vm, vm = vm,
@@ -659,6 +755,9 @@ private fun TelegramChatListContent(
onTabSelected: (MainTab) -> Unit, onTabSelected: (MainTab) -> Unit,
onOpenChat: (ChatDto) -> Unit, onOpenChat: (ChatDto) -> Unit,
onOpenContact: (ContactDto) -> Unit, onOpenContact: (ContactDto) -> Unit,
onImportPhoneContacts: () -> Unit,
onAddContact: (ContactDto) -> Unit,
onRemoveContact: (ContactDto) -> Unit,
onNewChat: () -> Unit, onNewChat: () -> Unit,
onCacheAvatar: (String) -> Unit, onCacheAvatar: (String) -> Unit,
selectionMenuOpen: Boolean, selectionMenuOpen: Boolean,
@@ -666,7 +765,8 @@ private fun TelegramChatListContent(
onClearSelection: () -> Unit, onClearSelection: () -> Unit,
onToggleChatSelection: (String) -> Unit, onToggleChatSelection: (String) -> Unit,
onBeginChatSelection: (String) -> Unit, onBeginChatSelection: (String) -> Unit,
onClearSelectedHistory: () -> Unit onClearSelectedHistory: () -> Unit,
onDeleteSelectedChats: () -> Unit
) { ) {
val selectedCount = state.selectedChatIds.size val selectedCount = state.selectedChatIds.size
val listTab = activeTab != MainTab.Settings val listTab = activeTab != MainTab.Settings
@@ -698,7 +798,8 @@ private fun TelegramChatListContent(
menuOpen = selectionMenuOpen, menuOpen = selectionMenuOpen,
onMenuOpenChange = onSelectionMenuOpenChange, onMenuOpenChange = onSelectionMenuOpenChange,
onClearSelection = onClearSelection, onClearSelection = onClearSelection,
onClearHistory = onClearSelectedHistory onClearHistory = onClearSelectedHistory,
onDeleteChats = onDeleteSelectedChats
) )
} else { } else {
TelegramLikeHeader( TelegramLikeHeader(
@@ -762,6 +863,8 @@ private fun TelegramChatListContent(
session = state.session, session = state.session,
cachedAvatarPaths = state.cachedAvatarPaths, cachedAvatarPaths = state.cachedAvatarPaths,
onCacheAvatar = onCacheAvatar, onCacheAvatar = onCacheAvatar,
onAdd = { onAddContact(contact) },
onRemove = { onRemoveContact(contact) },
onClick = { onOpenContact(contact) } onClick = { onOpenContact(contact) }
) )
} }
@@ -798,6 +901,19 @@ private fun TelegramChatListContent(
.padding(end = 22.dp, bottom = 96.dp), .padding(end = 22.dp, bottom = 96.dp),
onNewChat = onNewChat onNewChat = onNewChat
) )
} else if (activeTab == MainTab.Contacts) {
FloatingActionButton(
onClick = onImportPhoneContacts,
containerColor = QMaxBlue,
contentColor = Color.White,
shape = CircleShape,
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(end = 22.dp, bottom = 96.dp)
) {
Icon(Icons.Filled.Refresh, contentDescription = "Синхронизировать контакты телефона")
}
} }
TelegramBottomNavigation( TelegramBottomNavigation(
modifier = Modifier modifier = Modifier
@@ -902,7 +1018,8 @@ private fun ChatSelectionHeader(
menuOpen: Boolean, menuOpen: Boolean,
onMenuOpenChange: (Boolean) -> Unit, onMenuOpenChange: (Boolean) -> Unit,
onClearSelection: () -> Unit, onClearSelection: () -> Unit,
onClearHistory: () -> Unit onClearHistory: () -> Unit,
onDeleteChats: () -> Unit
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -951,6 +1068,13 @@ private fun ChatSelectionHeader(
onClearHistory() onClearHistory()
} }
) )
DropdownMenuItem(
text = { Text("Удалить чат") },
onClick = {
onMenuOpenChange(false)
onDeleteChats()
}
)
} }
} }
} }
@@ -1004,6 +1128,8 @@ private fun ContactRow(
session: QMaxSession?, session: QMaxSession?,
cachedAvatarPaths: Map<String, String>, cachedAvatarPaths: Map<String, String>,
onCacheAvatar: (String) -> Unit, onCacheAvatar: (String) -> Unit,
onAdd: () -> Unit,
onRemove: () -> Unit,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val subtitle = contact.status?.takeIf { it.isNotBlank() } val subtitle = contact.status?.takeIf { it.isNotBlank() }
@@ -1043,6 +1169,13 @@ private fun ContactRow(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
IconButton(onClick = if (contact.isSavedContact) onRemove else onAdd) {
Icon(
if (contact.isSavedContact) Icons.Filled.Delete else Icons.Filled.PersonAdd,
contentDescription = if (contact.isSavedContact) "Удалить из MAX" else "Добавить в MAX",
tint = if (contact.isSavedContact) QMaxMuted else QMaxBlue
)
}
Icon(Icons.Filled.Chat, contentDescription = "Открыть чат", tint = QMaxBlue) Icon(Icons.Filled.Chat, contentDescription = "Открыть чат", tint = QMaxBlue)
} }
} }
@@ -27,6 +27,7 @@ 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.ContactDto
import xyz.kusoft.qmax.core.model.PhoneContactDto
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
@@ -96,6 +97,18 @@ class QMaxRepository(
return withFreshSession(session) { api.contacts(it.serverUrl, it.accessToken) } return withFreshSession(session) { api.contacts(it.serverUrl, it.accessToken) }
} }
suspend fun importContacts(session: QMaxSession, contacts: List<PhoneContactDto>): List<ContactDto> {
return withFreshSession(session) { api.importContacts(it.serverUrl, it.accessToken, contacts) }
}
suspend fun addContact(session: QMaxSession, userId: String): ContactDto {
return withFreshSession(session) { api.addContact(it.serverUrl, it.accessToken, userId) }
}
suspend fun removeContact(session: QMaxSession, userId: String) {
withFreshSession(session) { api.removeContact(it.serverUrl, it.accessToken, userId) }
}
suspend fun createChat(session: QMaxSession, externalId: String, title: String, avatarUrl: String? = null): ChatDto { suspend fun createChat(session: QMaxSession, externalId: String, title: String, avatarUrl: String? = null): ChatDto {
val chat = withFreshSession(session) { val chat = withFreshSession(session) {
api.createDirect(it.serverUrl, it.accessToken, externalId, title, avatarUrl) api.createDirect(it.serverUrl, it.accessToken, externalId, title, avatarUrl)
@@ -144,6 +157,15 @@ class QMaxRepository(
} }
} }
suspend fun deleteChats(session: QMaxSession, chatIds: Collection<String>) {
val ids = chatIds.filter(String::isNotBlank).distinct()
if (ids.isEmpty()) return
withFreshSession(session) { api.deleteChats(it.serverUrl, it.accessToken, ids) }
val remainingChats = cachedChats(session).filterNot { it.id in ids }
messageCache.saveChats(session, orderedChats(remainingChats))
messageCache.clearMessages(session, ids)
}
suspend fun searchMessages(session: QMaxSession, chatId: String, query: String): List<MessageDto> { suspend fun searchMessages(session: QMaxSession, chatId: String, query: String): List<MessageDto> {
val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) } val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) }
messages.forEach { messageCache.upsertMessage(session, it) } messages.forEach { messageCache.upsertMessage(session, it) }
@@ -56,9 +56,23 @@ data class ContactDto(
val displayName: String, val displayName: String,
val avatarUrl: String? = null, val avatarUrl: String? = null,
val phoneNumber: String? = null, val phoneNumber: String? = null,
val status: String? = null val status: String? = null,
val isSavedContact: Boolean = false
) )
@Serializable
data class PhoneContactDto(
val phoneNumber: String,
val firstName: String,
val lastName: String? = null
)
@Serializable
data class ImportPhoneContactsRequest(val contacts: List<PhoneContactDto>)
@Serializable
data class ContactUserRequest(val userId: String)
@Serializable @Serializable
data class MessageDto( data class MessageDto(
val id: String, val id: String,
@@ -27,13 +27,16 @@ 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.ContactDto
import xyz.kusoft.qmax.core.model.ContactUserRequest
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
import xyz.kusoft.qmax.core.model.MarkChatReadRequest import xyz.kusoft.qmax.core.model.MarkChatReadRequest
import xyz.kusoft.qmax.core.model.ImportPhoneContactsRequest
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
import xyz.kusoft.qmax.core.model.PhoneContactDto
import xyz.kusoft.qmax.core.model.RegisterPushDeviceRequest import xyz.kusoft.qmax.core.model.RegisterPushDeviceRequest
import xyz.kusoft.qmax.core.model.SendMessageRequest import xyz.kusoft.qmax.core.model.SendMessageRequest
import xyz.kusoft.qmax.core.model.SetReactionRequest import xyz.kusoft.qmax.core.model.SetReactionRequest
@@ -82,6 +85,18 @@ class QMaxApi {
return get(sessionServerUrl, "/api/contacts", token) return get(sessionServerUrl, "/api/contacts", token)
} }
suspend fun importContacts(sessionServerUrl: String, token: String, contacts: List<PhoneContactDto>): List<ContactDto> {
return post(sessionServerUrl, "/api/contacts/import", token, ImportPhoneContactsRequest(contacts))
}
suspend fun addContact(sessionServerUrl: String, token: String, userId: String): ContactDto {
return post(sessionServerUrl, "/api/contacts/add", token, ContactUserRequest(userId))
}
suspend fun removeContact(sessionServerUrl: String, token: String, userId: String) {
postNoContent(sessionServerUrl, "/api/contacts/remove", token, ContactUserRequest(userId))
}
suspend fun createDirect( suspend fun createDirect(
sessionServerUrl: String, sessionServerUrl: String,
token: String, token: String,
@@ -104,6 +119,10 @@ class QMaxApi {
postNoContent(sessionServerUrl, "/api/chats/clear-history", token, ChatBulkActionRequest(chatIds)) postNoContent(sessionServerUrl, "/api/chats/clear-history", token, ChatBulkActionRequest(chatIds))
} }
suspend fun deleteChats(sessionServerUrl: String, token: String, chatIds: List<String>) {
postNoContent(sessionServerUrl, "/api/chats/delete", token, ChatBulkActionRequest(chatIds))
}
suspend fun searchMessages(sessionServerUrl: String, token: String, chatId: String, query: String): List<MessageDto> { suspend fun searchMessages(sessionServerUrl: String, token: String, chatId: String, query: String): List<MessageDto> {
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString()) val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token) return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token)
@@ -21,6 +21,7 @@ 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
import xyz.kusoft.qmax.core.model.MessageDto import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.PhoneContactDto
import xyz.kusoft.qmax.core.model.QMaxSession import xyz.kusoft.qmax.core.model.QMaxSession
import xyz.kusoft.qmax.core.network.QMaxHttpException import xyz.kusoft.qmax.core.network.QMaxHttpException
import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient
@@ -420,6 +421,64 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
loadChats() loadChats()
} }
fun reportContactsPermissionDenied() {
state.value = state.value.copy(
contactsLoading = false,
contactsError = "Разрешите QMAX доступ к контактам телефона и обновите список"
)
}
fun importPhoneContacts(contacts: List<PhoneContactDto>) = viewModelScope.launch {
state.value = state.value.copy(contactsLoading = true, contactsError = null, error = null)
if (contacts.isEmpty()) {
state.value = state.value.copy(
contacts = emptyList(),
contactsLoading = false,
contactsError = "В телефонной книге нет контактов с номерами"
)
return@launch
}
try {
val session = requireSession()
val imported = repository.importContacts(session, contacts)
state.value = state.value.copy(
contacts = imported.sortedBy { it.displayName.lowercase() },
contactsLoading = false,
contactsError = if (imported.isEmpty()) {
"MAX не сопоставил ни один из ${contacts.size} номеров телефонной книги"
} else {
null
},
updateMessage = "MAX сопоставил контактов: ${imported.size}"
)
} catch (error: Throwable) {
if (error is CancellationException) throw error
Log.w(LogTag, "Phone contacts import failed", error)
state.value = state.value.copy(
contactsLoading = false,
contactsError = error.message ?: "Не удалось синхронизировать контакты"
)
}
}
fun addContact(contact: ContactDto) = launchLoading(false) {
val session = requireSession()
val updated = repository.addContact(session, contact.userId)
state.value = state.value.copy(
contacts = state.value.contacts.map { if (it.userId == updated.userId) updated else it }
)
}
fun removeContact(contact: ContactDto) = launchLoading(false) {
val session = requireSession()
repository.removeContact(session, contact.userId)
state.value = state.value.copy(
contacts = state.value.contacts.map {
if (it.userId == contact.userId) it.copy(isSavedContact = false) else it
}
)
}
fun openChat(chat: ChatDto) { fun openChat(chat: ChatDto) {
saveCurrentDraft() saveCurrentDraft()
chatSearchJob?.cancel() chatSearchJob?.cancel()
@@ -550,6 +609,17 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
refreshChats(showLoading = false) refreshChats(showLoading = false)
} }
fun deleteSelectedChats() = launchLoading(false) {
val session = requireSession()
val ids = state.value.selectedChatIds
if (ids.isEmpty()) return@launchLoading
repository.deleteChats(session, ids)
state.value = state.value.copy(
chats = state.value.chats.filterNot { it.id in ids },
selectedChatIds = emptySet()
)
}
fun loadMessages(showLoading: Boolean = true, forceHydrate: Boolean = false) = launchLoading(showLoading) { fun loadMessages(showLoading: Boolean = true, forceHydrate: Boolean = false) = launchLoading(showLoading) {
if (!showLoading && state.value.sendingMessage) { if (!showLoading && state.value.sendingMessage) {
return@launchLoading return@launchLoading
+175 -23
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import json
import mimetypes import mimetypes
import os import os
import pathlib import pathlib
@@ -18,12 +19,14 @@ from typing import Any
import aiohttp import aiohttp
from aiohttp import web from aiohttp import web
from pymax import Client, ExtraConfig, File, Photo, SyncOverrides, Video from pymax import Client, ExtraConfig, File, Photo, SyncOverrides, Video
from pymax.types import ContactInfo
PORT = int(os.environ.get("PORT", "3002")) PORT = int(os.environ.get("PORT", "3002"))
PHONE_NUMBER = os.environ.get("PYMAX_PHONE_NUMBER") or os.environ.get("QMAX_MAX_PHONE_NUMBER") or "" PHONE_NUMBER = os.environ.get("PYMAX_PHONE_NUMBER") or os.environ.get("QMAX_MAX_PHONE_NUMBER") or ""
SESSION_DIR = pathlib.Path(os.environ.get("PYMAX_SESSION_DIR", "/data/pymax-session")) SESSION_DIR = pathlib.Path(os.environ.get("PYMAX_SESSION_DIR", "/data/pymax-session"))
SESSION_NAME = os.environ.get("PYMAX_SESSION_NAME", "session.db") SESSION_NAME = os.environ.get("PYMAX_SESSION_NAME", "session.db")
PHONE_CONTACT_IDS_FILE = SESSION_DIR / "phone-contact-ids.json"
CHAT_FETCH_LIMIT = int(os.environ.get("PYMAX_CHAT_FETCH_LIMIT", "350")) CHAT_FETCH_LIMIT = int(os.environ.get("PYMAX_CHAT_FETCH_LIMIT", "350"))
HISTORY_LIMIT = int(os.environ.get("PYMAX_HISTORY_LIMIT", "80")) HISTORY_LIMIT = int(os.environ.get("PYMAX_HISTORY_LIMIT", "80"))
SEND_ROOTS = [pathlib.Path(p) for p in os.environ.get("PYMAX_SEND_ROOTS", "/qmax-data:/tmp").split(":") if p] SEND_ROOTS = [pathlib.Path(p) for p in os.environ.get("PYMAX_SEND_ROOTS", "/qmax-data:/tmp").split(":") if p]
@@ -56,6 +59,11 @@ def normalize_phone(phone: str) -> str:
return phone.strip() return phone.strip()
def phone_key(phone: Any) -> str:
normalized = normalize_phone(str(phone or ""))
return "".join(ch for ch in normalized if ch.isdigit())
def is_expired_session_error(error: str | None) -> bool: def is_expired_session_error(error: str | None) -> bool:
text = (error or "").lower() text = (error or "").lower()
return any( return any(
@@ -253,7 +261,13 @@ 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: def contact_result(
contact: Any,
me_id: int,
external_chat_id: Any | None = None,
*,
is_saved_contact: bool = False,
) -> dict[str, Any] | None:
data = dump_model(contact) data = dump_model(contact)
contact_id = coerce_int(data.get("id") or getattr(contact, "id", None)) 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: if contact_id is None or contact_id <= 0 or contact_id == me_id:
@@ -265,6 +279,7 @@ def contact_result(contact: Any, me_id: int, external_chat_id: Any | None = None
"avatarUrl": user_avatar_url(contact), "avatarUrl": user_avatar_url(contact),
"phoneNumber": first_text(data.get("phone")), "phoneNumber": first_text(data.get("phone")),
"status": first_text(data.get("status"), data.get("description")), "status": first_text(data.get("status"), data.get("description")),
"isSavedContact": is_saved_contact,
} }
@@ -523,10 +538,6 @@ 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()
@@ -935,27 +946,151 @@ async def contacts(_request: web.Request) -> web.Response:
me_id = get_me_user_id(client) me_id = get_me_user_id(client)
if me_id is None: if me_id is None:
return json_response({"success": False, "error": "MAX profile has no user id."}, status=503) return json_response({"success": False, "error": "MAX profile has no user id."}, status=503)
matched_ids = load_phone_contact_ids()
by_user_id: dict[str, dict[str, Any]] = {} if not matched_ids:
for contact in client.contacts: return json_response([])
normalized = contact_result(contact, me_id) if contact is not None else None users = await client.get_users(sorted(matched_ids))
if normalized is not None: saved_ids = {
by_user_id[normalized["userId"]] = normalized coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None))
for contact in client.contacts
chats = await fetch_chat_pages(client, CHAT_FETCH_LIMIT) if contact is not None
direct_chats = [chat for chat in chats if is_direct_dialog(chat)] }
user_map = await build_user_map(client, direct_chats) result = [
for chat in direct_chats: normalized
contact = chat_other_user(chat, user_map, me_id) for contact in users
normalized = contact_result(contact, me_id, getattr(chat, "id", None)) if contact is not None else None if (normalized := contact_result(
if normalized is not None: contact,
by_user_id.setdefault(normalized["userId"], normalized) me_id,
is_saved_contact=coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None)) in saved_ids,
result = list(by_user_id.values()) )) is not None
]
result.sort(key=lambda item: item["displayName"].casefold()) result.sort(key=lambda item: item["displayName"].casefold())
return json_response(result) return json_response(result)
def load_phone_contact_ids() -> set[int]:
try:
values = json.loads(PHONE_CONTACT_IDS_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return set()
return {value for item in values if (value := coerce_int(item)) is not None and value > 0}
def save_phone_contact_ids(contact_ids: set[int]) -> None:
SESSION_DIR.mkdir(parents=True, exist_ok=True)
part_path = PHONE_CONTACT_IDS_FILE.with_suffix(".json.part")
part_path.write_text(json.dumps(sorted(contact_ids)), encoding="utf-8")
part_path.replace(PHONE_CONTACT_IDS_FILE)
def append_saved_contacts(client: Client, contacts: list[Any]) -> None:
saved = client.contacts
saved_ids = {
coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None))
for contact in saved
if contact is not None
}
for contact in contacts:
contact_id = coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None))
if contact_id is not None and contact_id not in saved_ids:
saved.append(contact)
saved_ids.add(contact_id)
@route_errors
async def contacts_import(request: web.Request) -> web.Response:
data = await read_json(request)
raw_contacts = data.get("contacts") if isinstance(data.get("contacts"), list) else []
if not raw_contacts:
return json_response({"success": False, "error": "contacts are required"}, status=400)
if len(raw_contacts) > 5000:
return json_response({"success": False, "error": "contacts limit is 5000"}, status=400)
contacts_to_import: list[ContactInfo] = []
requested_phone_keys: set[str] = set()
for item in raw_contacts:
if not isinstance(item, dict):
continue
phone = normalize_phone(str(item.get("phoneNumber") or item.get("phone") or "").strip())
first_name = str(item.get("firstName") or item.get("name") or phone).strip()
last_name = str(item.get("lastName") or "").strip() or None
key = phone_key(phone)
if phone and key and key not in requested_phone_keys:
requested_phone_keys.add(key)
contacts_to_import.append(ContactInfo(phone=phone, first_name=first_name or phone, last_name=last_name))
if not contacts_to_import:
return json_response({"success": False, "error": "contacts contain no phone numbers"}, status=400)
client = await runtime.get_client()
imported = list(await client.import_contacts(contacts_to_import) or [])
append_saved_contacts(client, imported)
# PyMax's SYNC response may omit contacts that were already saved in MAX.
# Merge those cached contacts back by phone so a repeated phone-book sync
# produces the same intersection instead of replacing it with an empty set.
matched_by_id: dict[int, Any] = {}
for contact in imported:
contact_id = coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None))
if contact_id is not None and contact_id > 0:
matched_by_id[contact_id] = contact
for contact in client.contacts:
contact_data = dump_model(contact)
contact_id = coerce_int(contact_data.get("id") or getattr(contact, "id", None))
if (
contact_id is not None
and contact_id > 0
and phone_key(contact_data.get("phone") or getattr(contact, "phone", None)) in requested_phone_keys
):
matched_by_id[contact_id] = contact
matched = list(matched_by_id.values())
imported_ids = {
contact_id
for contact in matched
if (contact_id := coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None))) is not None
and contact_id > 0
}
save_phone_contact_ids(imported_ids)
me_id = get_me_user_id(client)
result = [
normalized
for contact in matched
if me_id is not None
if (normalized := contact_result(contact, me_id, is_saved_contact=True)) is not None
]
return json_response(result)
@route_errors
async def contacts_add(request: web.Request) -> web.Response:
data = await read_json(request)
user_id = coerce_int(data.get("userId"))
if user_id is None or user_id <= 0:
return json_response({"success": False, "error": "valid userId is required"}, status=400)
client = await runtime.get_client()
contact = await client.add_contact(user_id)
append_saved_contacts(client, [contact])
me_id = get_me_user_id(client)
normalized = contact_result(contact, me_id, is_saved_contact=True) if me_id is not None else None
return json_response(normalized or {"success": False, "error": "contact could not be normalized"})
@route_errors
async def contacts_remove(request: web.Request) -> web.Response:
data = await read_json(request)
user_id = coerce_int(data.get("userId"))
if user_id is None or user_id <= 0:
return json_response({"success": False, "error": "valid userId is required"}, status=400)
client = await runtime.get_client()
await client.remove_contact(user_id)
client.contacts[:] = [
contact
for contact in client.contacts
if contact is not None and coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None)) != user_id
]
return json_response({"success": True, "error": None})
@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)
@@ -1035,6 +1170,20 @@ async def disabled_action(_request: web.Request) -> web.Response:
return json_response({"success": False, "error": "Not implemented in PyMax POC worker."}) return json_response({"success": False, "error": "Not implemented in PyMax POC worker."})
@route_errors
async def chat_delete(request: web.Request) -> web.Response:
data = await read_json(request)
client = await runtime.get_client()
chat_id = parse_chat_id(data.get("externalChatId") or data.get("chatUrl"))
chat = await client.get_chat(chat_id)
await client.delete_chat(
chat_id,
last_event_time=getattr(chat, "last_event_time", None),
for_all=False,
)
return json_response({"success": True, "error": None})
@route_errors @route_errors
async def send_text(request: web.Request) -> web.Response: async def send_text(request: web.Request) -> web.Response:
data = await read_json(request) data = await read_json(request)
@@ -1083,13 +1232,16 @@ def create_app() -> web.Application:
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_get("/contacts", contacts)
app.router.add_post("/contacts/import", contacts_import)
app.router.add_post("/contacts/add", contacts_add)
app.router.add_post("/contacts/remove", contacts_remove)
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)
app.router.add_post("/channels/join", channels_join) app.router.add_post("/channels/join", channels_join)
app.router.add_post("/chat/presence", chat_presence) app.router.add_post("/chat/presence", chat_presence)
app.router.add_post("/chat/clear-history", disabled_action) app.router.add_post("/chat/clear-history", disabled_action)
app.router.add_post("/chat/delete", disabled_action) app.router.add_post("/chat/delete", chat_delete)
app.router.add_post("/send/text", send_text) app.router.add_post("/send/text", send_text)
app.router.add_post("/send/attachment", send_attachment) app.router.add_post("/send/attachment", send_attachment)
app.router.add_post("/message/edit", disabled_action) app.router.add_post("/message/edit", disabled_action)
+6 -1
View File
@@ -59,4 +59,9 @@ public sealed record ContactDto(
string DisplayName, string DisplayName,
string? AvatarUrl, string? AvatarUrl,
string? PhoneNumber, string? PhoneNumber,
string? Status); string? Status,
bool IsSavedContact);
public sealed record PhoneContactDto(string PhoneNumber, string FirstName, string? LastName = null);
public sealed record ImportPhoneContactsRequest(IReadOnlyList<PhoneContactDto> Contacts);
public sealed record ContactUserRequest(string UserId);
+49 -3
View File
@@ -51,8 +51,18 @@ public sealed class ChatsController(
Kind = ChatKind.MaxDialog Kind = ChatKind.MaxDialog
}; };
db.Chats.Add(chat); db.Chats.Add(chat);
await db.SaveChangesAsync(cancellationToken);
} }
else
{
chat.Title = request.Title;
chat.AvatarUrl = request.AvatarUrl;
chat.DeletedAt = null;
chat.PendingMaxAction = null;
chat.PendingMaxActionError = null;
chat.UpdatedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
return projection.ToDto(chat); return projection.ToDto(chat);
@@ -274,7 +284,7 @@ public sealed class ChatsController(
} }
[HttpPost("delete")] [HttpPost("delete")]
public IActionResult DeleteChats(ChatBulkActionRequest request) public async Task<IActionResult> DeleteChats(ChatBulkActionRequest request, CancellationToken cancellationToken)
{ {
var chatIds = request.ChatIds.Distinct().ToArray(); var chatIds = request.ChatIds.Distinct().ToArray();
if (chatIds.Length == 0) if (chatIds.Length == 0)
@@ -282,7 +292,43 @@ public sealed class ChatsController(
return BadRequest("chatIds are required."); return BadRequest("chatIds are required.");
} }
return BadRequest("Chat deletion is disabled because MAX does not remove chats from the web client."); var chats = await LoadChatsWithAttachmentsAsync(chatIds, cancellationToken);
if (chats.Count != chatIds.Length || chats.Any(chat => chat.DeletedAt is not null))
{
return NotFound();
}
foreach (var chat in chats)
{
var maxFailure = await TryApplyMaxChatActionAsync(
chat,
(externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken),
"delete chat",
cancellationToken);
if (maxFailure is not null)
{
return maxFailure;
}
}
var now = DateTimeOffset.UtcNow;
var files = AttachmentFiles(chats).ToArray();
foreach (var chat in chats)
{
db.Messages.RemoveRange(chat.Messages);
chat.LastMessageAt = null;
chat.LastMessagePreview = null;
chat.UnreadCount = 0;
chat.DeletedAt = now;
chat.UpdatedAt = now;
chat.PendingMaxAction = null;
chat.PendingMaxActionError = null;
}
await db.SaveChangesAsync(cancellationToken);
DeleteFiles(files);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
return NoContent();
} }
[HttpGet("{chatId:guid}/search")] [HttpGet("{chatId:guid}/search")]
@@ -21,8 +21,62 @@ public sealed class ContactsController(IMaxBridgeClient maxBridge) : ControllerB
contact.DisplayName, contact.DisplayName,
contact.AvatarUrl, contact.AvatarUrl,
contact.PhoneNumber, contact.PhoneNumber,
contact.Status)) contact.Status,
contact.IsSavedContact))
.OrderBy(contact => contact.DisplayName, StringComparer.CurrentCultureIgnoreCase) .OrderBy(contact => contact.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.ToArray(); .ToArray();
} }
[HttpPost("import")]
public async Task<ActionResult<IReadOnlyList<ContactDto>>> ImportContacts(
ImportPhoneContactsRequest request,
CancellationToken cancellationToken)
{
var contacts = request.Contacts
.Where(contact => !string.IsNullOrWhiteSpace(contact.PhoneNumber))
.Take(5000)
.Select(contact => new MaxPhoneContact(
contact.PhoneNumber.Trim(),
string.IsNullOrWhiteSpace(contact.FirstName) ? contact.PhoneNumber.Trim() : contact.FirstName.Trim(),
contact.LastName?.Trim()))
.ToArray();
if (contacts.Length == 0)
{
return BadRequest("contacts with phone numbers are required.");
}
var imported = await maxBridge.ImportContactsAsync(contacts, cancellationToken);
return imported.Select(ToDto).ToArray();
}
[HttpPost("add")]
public async Task<ActionResult<ContactDto>> AddContact(ContactUserRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.UserId))
{
return BadRequest("userId is required.");
}
var contact = await maxBridge.AddContactAsync(request.UserId.Trim(), cancellationToken);
return contact is null ? BadRequest("MAX did not add the contact.") : ToDto(contact);
}
[HttpPost("remove")]
public async Task<IActionResult> RemoveContact(ContactUserRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.UserId))
{
return BadRequest("userId is required.");
}
var result = await maxBridge.RemoveContactAsync(request.UserId.Trim(), cancellationToken);
return result.Success ? NoContent() : BadRequest(result.Error ?? "MAX did not remove the contact.");
}
private static ContactDto ToDto(MaxContact contact) => new(
contact.UserId,
contact.ExternalChatId,
contact.DisplayName,
contact.AvatarUrl,
contact.PhoneNumber,
contact.Status,
contact.IsSavedContact);
} }
@@ -11,6 +11,18 @@ public interface IMaxBridgeClient
{ {
return Task.FromResult<IReadOnlyList<MaxContact>>(Array.Empty<MaxContact>()); return Task.FromResult<IReadOnlyList<MaxContact>>(Array.Empty<MaxContact>());
} }
Task<IReadOnlyList<MaxContact>> ImportContactsAsync(IReadOnlyList<MaxPhoneContact> contacts, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<MaxContact>>(Array.Empty<MaxContact>());
}
Task<MaxContact?> AddContactAsync(string userId, CancellationToken cancellationToken)
{
return Task.FromResult<MaxContact?>(null);
}
Task<MaxActionResult> RemoveContactAsync(string userId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(false, "Contact removal is not supported."));
}
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);
@@ -80,4 +80,7 @@ public sealed record MaxContact(
string DisplayName, string DisplayName,
string? AvatarUrl, string? AvatarUrl,
string? PhoneNumber, string? PhoneNumber,
string? Status); string? Status,
bool IsSavedContact = false);
public sealed record MaxPhoneContact(string PhoneNumber, string FirstName, string? LastName);
@@ -48,10 +48,25 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
public Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken) public Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
{ {
return Task.FromResult<IReadOnlyList<MaxContact>>([ return Task.FromResult<IReadOnlyList<MaxContact>>([
new MaxContact("mock-user", "mock-direct", "Mock contact", null, "+70000000000", "online") new MaxContact("mock-user", "mock-direct", "Mock contact", null, "+70000000000", "online", true)
]); ]);
} }
public Task<IReadOnlyList<MaxContact>> ImportContactsAsync(IReadOnlyList<MaxPhoneContact> contacts, CancellationToken cancellationToken)
{
return FetchContactsAsync(cancellationToken);
}
public async Task<MaxContact?> AddContactAsync(string userId, CancellationToken cancellationToken)
{
return (await FetchContactsAsync(cancellationToken)).FirstOrDefault();
}
public Task<MaxActionResult> RemoveContactAsync(string userId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
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);
@@ -75,9 +90,7 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult(new MaxActionResult( return Task.FromResult(new MaxActionResult(true, null));
false,
"Chat deletion is disabled because MAX does not remove chats from the web client."));
} }
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
@@ -49,6 +49,29 @@ public sealed class WorkerMaxBridgeClient(
?? Array.Empty<MaxContact>(); ?? Array.Empty<MaxContact>();
} }
public async Task<IReadOnlyList<MaxContact>> ImportContactsAsync(
IReadOnlyList<MaxPhoneContact> contacts,
CancellationToken cancellationToken)
{
return await SendAsync<IReadOnlyList<MaxContact>>(
HttpMethod.Post,
"/contacts/import",
new { contacts },
cancellationToken)
?? Array.Empty<MaxContact>();
}
public async Task<MaxContact?> AddContactAsync(string userId, CancellationToken cancellationToken)
{
return await SendAsync<MaxContact>(HttpMethod.Post, "/contacts/add", new { userId }, cancellationToken);
}
public async Task<MaxActionResult> RemoveContactAsync(string userId, CancellationToken cancellationToken)
{
return await SendAsync<MaxActionResult>(HttpMethod.Post, "/contacts/remove", new { userId }, cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty contact removal result.");
}
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);
@@ -75,11 +98,14 @@ public sealed class WorkerMaxBridgeClient(
?? new MaxActionResult(false, "Worker returned an empty clear history result."); ?? new MaxActionResult(false, "Worker returned an empty clear history result.");
} }
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) public async Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult(new MaxActionResult( return await SendAsync<MaxActionResult>(
false, HttpMethod.Post,
"Chat deletion is disabled because MAX does not remove chats from the web client.")); "/chat/delete",
new { externalChatId, chatUrl },
cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty chat deletion result.");
} }
public async Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) public async Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
+4 -3
View File
@@ -125,9 +125,10 @@ public sealed class MaxOutboxService(
chat, chat,
(externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken), (externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken),
cancellationToken), cancellationToken),
ChatPendingMaxAction.DeleteChat => new MaxActionResult( ChatPendingMaxAction.DeleteChat => await ApplyMaxChatActionAsync(
false, chat,
"Chat deletion is disabled because MAX does not remove chats from the web client."), (externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken),
cancellationToken),
_ => new MaxActionResult(false, $"Unsupported chat action: {pendingAction.Value}.") _ => new MaxActionResult(false, $"Unsupported chat action: {pendingAction.Value}.")
}; };
} }
+16
View File
@@ -125,6 +125,14 @@ public sealed class ApiSmokeTests : IDisposable
var contact = Assert.Single(contacts!); var contact = Assert.Single(contacts!);
Assert.Equal("mock-direct", contact.ExternalChatId); Assert.Equal("mock-direct", contact.ExternalChatId);
Assert.Equal("Mock contact", contact.DisplayName); Assert.Equal("Mock contact", contact.DisplayName);
Assert.True(contact.IsSavedContact);
var importedResponse = await client.PostAsJsonAsync(
"/api/contacts/import",
new ImportPhoneContactsRequest([new PhoneContactDto("+70000000000", "Mock contact")]));
await AssertStatusAsync(HttpStatusCode.OK, importedResponse);
var imported = await importedResponse.Content.ReadFromJsonAsync<ContactDto[]>(JsonOptions);
Assert.Single(imported!);
var response = await client.PostAsJsonAsync( var response = await client.PostAsJsonAsync(
"/api/chats/direct", "/api/chats/direct",
@@ -134,6 +142,14 @@ public sealed class ApiSmokeTests : IDisposable
Assert.NotNull(chat); Assert.NotNull(chat);
Assert.Equal(contact.ExternalChatId, chat!.ExternalId); Assert.Equal(contact.ExternalChatId, chat!.ExternalId);
Assert.Equal(contact.DisplayName, chat.Title); Assert.Equal(contact.DisplayName, chat.Title);
var removeContact = await client.PostAsJsonAsync("/api/contacts/remove", new ContactUserRequest(contact.UserId));
await AssertStatusAsync(HttpStatusCode.NoContent, removeContact);
var deleteChat = await client.PostAsJsonAsync("/api/chats/delete", new ChatBulkActionRequest([chat.Id]));
await AssertStatusAsync(HttpStatusCode.NoContent, deleteChat);
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.DoesNotContain(chats!, candidate => candidate.Id == chat.Id);
} }
[Fact] [Fact]