Sync phone contacts and enable chat deletion
This commit is contained in:
@@ -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\"")
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}.")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user