diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4f5bd83..b21c949 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -22,8 +22,8 @@ android { applicationId = "xyz.kusoft.qmax" minSdk = 26 targetSdk = 36 - versionCode = 52 - versionName = "0.1.51" + versionCode = 54 + versionName = "0.1.53" buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"") buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a85f5da..d723d01 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt index 30863db..1715aef 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -2,6 +2,7 @@ package xyz.kusoft.qmax import android.Manifest import android.content.Intent +import android.content.ContentResolver import android.content.pm.PackageManager import android.graphics.BitmapFactory import android.media.MediaRecorder @@ -9,6 +10,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.OpenableColumns +import android.provider.ContactsContract import android.view.WindowManager import android.widget.Toast 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.Pause 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.PushPin 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.MaxChannelSearchResultDto 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.ui.QMaxUiState 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 readPhoneContacts(contentResolver: ContentResolver): List { + val projection = arrayOf( + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Phone.NUMBER, + ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER + ) + val seenNumbers = mutableSetOf() + val result = mutableListOf() + 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 private fun QMaxApp(vm: QMaxViewModel) { val state by vm.state @@ -344,7 +381,8 @@ private fun LoginScreen(vm: QMaxViewModel) { } private enum class ChatBulkUiAction { - ClearHistory + ClearHistory, + DeleteChats } private enum class MainTab { @@ -362,6 +400,7 @@ private fun ChatListScreen(vm: QMaxViewModel) { var menuOpen by remember { mutableStateOf(false) } var selectionMenuOpen by remember { mutableStateOf(false) } var pendingBulkAction by remember { mutableStateOf(null) } + var pendingContactRemoval by remember { mutableStateOf(null) } var searchMode by remember { mutableStateOf(false) } var newChatOpen by remember { mutableStateOf(false) } 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) { 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( state = state, filteredChats = filteredChats, @@ -410,7 +478,7 @@ private fun ChatListScreen(vm: QMaxViewModel) { onMenuOpenChange = { menuOpen = it }, onRefresh = { vm.loadChats() - vm.loadContacts() + if (activeTab == MainTab.Contacts) requestPhoneContactsImport() }, onCheckUpdates = vm::checkArgusUpdate, onRefreshMaxStatus = vm::loadMaxStatus, @@ -427,6 +495,9 @@ private fun ChatListScreen(vm: QMaxViewModel) { }, onOpenChat = vm::openChat, onOpenContact = vm::openContact, + onImportPhoneContacts = requestPhoneContactsImport, + onAddContact = vm::addContact, + onRemoveContact = { pendingContactRemoval = it }, onNewChat = { activeTab = MainTab.Contacts vm.updateSearchQuery("") @@ -440,6 +511,10 @@ private fun ChatListScreen(vm: QMaxViewModel) { onClearSelectedHistory = { selectionMenuOpen = false pendingBulkAction = ChatBulkUiAction.ClearHistory + }, + onDeleteSelectedChats = { + selectionMenuOpen = false + pendingBulkAction = ChatBulkUiAction.DeleteChats } ) @@ -451,6 +526,7 @@ private fun ChatListScreen(vm: QMaxViewModel) { Text( when (action) { ChatBulkUiAction.ClearHistory -> "Очистить историю" + ChatBulkUiAction.DeleteChats -> "Удалить чаты" } ) }, @@ -458,6 +534,7 @@ private fun ChatListScreen(vm: QMaxViewModel) { Text( when (action) { ChatBulkUiAction.ClearHistory -> "Очистить историю в выбранных чатах: $selectedCount?" + ChatBulkUiAction.DeleteChats -> "Удалить выбранные чаты из MAX: $selectedCount? Это действие нельзя отменить." } ) }, @@ -467,12 +544,14 @@ private fun ChatListScreen(vm: QMaxViewModel) { pendingBulkAction = null when (action) { ChatBulkUiAction.ClearHistory -> vm.clearSelectedChatHistories() + ChatBulkUiAction.DeleteChats -> vm.deleteSelectedChats() } } ) { Text( 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) { NewChatDialog( vm = vm, @@ -659,6 +755,9 @@ private fun TelegramChatListContent( onTabSelected: (MainTab) -> Unit, onOpenChat: (ChatDto) -> Unit, onOpenContact: (ContactDto) -> Unit, + onImportPhoneContacts: () -> Unit, + onAddContact: (ContactDto) -> Unit, + onRemoveContact: (ContactDto) -> Unit, onNewChat: () -> Unit, onCacheAvatar: (String) -> Unit, selectionMenuOpen: Boolean, @@ -666,7 +765,8 @@ private fun TelegramChatListContent( onClearSelection: () -> Unit, onToggleChatSelection: (String) -> Unit, onBeginChatSelection: (String) -> Unit, - onClearSelectedHistory: () -> Unit + onClearSelectedHistory: () -> Unit, + onDeleteSelectedChats: () -> Unit ) { val selectedCount = state.selectedChatIds.size val listTab = activeTab != MainTab.Settings @@ -698,7 +798,8 @@ private fun TelegramChatListContent( menuOpen = selectionMenuOpen, onMenuOpenChange = onSelectionMenuOpenChange, onClearSelection = onClearSelection, - onClearHistory = onClearSelectedHistory + onClearHistory = onClearSelectedHistory, + onDeleteChats = onDeleteSelectedChats ) } else { TelegramLikeHeader( @@ -762,6 +863,8 @@ private fun TelegramChatListContent( session = state.session, cachedAvatarPaths = state.cachedAvatarPaths, onCacheAvatar = onCacheAvatar, + onAdd = { onAddContact(contact) }, + onRemove = { onRemoveContact(contact) }, onClick = { onOpenContact(contact) } ) } @@ -798,6 +901,19 @@ private fun TelegramChatListContent( .padding(end = 22.dp, bottom = 96.dp), 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( modifier = Modifier @@ -902,7 +1018,8 @@ private fun ChatSelectionHeader( menuOpen: Boolean, onMenuOpenChange: (Boolean) -> Unit, onClearSelection: () -> Unit, - onClearHistory: () -> Unit + onClearHistory: () -> Unit, + onDeleteChats: () -> Unit ) { Row( modifier = Modifier @@ -951,6 +1068,13 @@ private fun ChatSelectionHeader( onClearHistory() } ) + DropdownMenuItem( + text = { Text("Удалить чат") }, + onClick = { + onMenuOpenChange(false) + onDeleteChats() + } + ) } } } @@ -1004,6 +1128,8 @@ private fun ContactRow( session: QMaxSession?, cachedAvatarPaths: Map, onCacheAvatar: (String) -> Unit, + onAdd: () -> Unit, + onRemove: () -> Unit, onClick: () -> Unit ) { val subtitle = contact.status?.takeIf { it.isNotBlank() } @@ -1043,6 +1169,13 @@ private fun ContactRow( 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) } } diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt index e7ef448..4181331 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt @@ -27,6 +27,7 @@ import xyz.kusoft.qmax.core.model.AuthResponse import xyz.kusoft.qmax.core.model.ChatDto import xyz.kusoft.qmax.core.model.ChatPresenceDto 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.MaxChannelSearchResultDto import xyz.kusoft.qmax.core.model.MessageDto @@ -96,6 +97,18 @@ class QMaxRepository( return withFreshSession(session) { api.contacts(it.serverUrl, it.accessToken) } } + suspend fun importContacts(session: QMaxSession, contacts: List): List { + 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 { val chat = withFreshSession(session) { api.createDirect(it.serverUrl, it.accessToken, externalId, title, avatarUrl) @@ -144,6 +157,15 @@ class QMaxRepository( } } + suspend fun deleteChats(session: QMaxSession, chatIds: Collection) { + 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 { val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) } messages.forEach { messageCache.upsertMessage(session, it) } diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt index 060a955..5181f1c 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt @@ -56,9 +56,23 @@ data class ContactDto( val displayName: String, val avatarUrl: 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) + +@Serializable +data class ContactUserRequest(val userId: String) + @Serializable data class MessageDto( val id: String, diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt index a79ba1d..6f787e5 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt @@ -27,13 +27,16 @@ import xyz.kusoft.qmax.core.model.ChatDto import xyz.kusoft.qmax.core.model.ChatPresenceDto import xyz.kusoft.qmax.core.model.CreateDirectChatRequest 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.EditMessageRequest import xyz.kusoft.qmax.core.model.ForwardMessageRequest 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.MaxChannelSearchResultDto 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.SendMessageRequest import xyz.kusoft.qmax.core.model.SetReactionRequest @@ -82,6 +85,18 @@ class QMaxApi { return get(sessionServerUrl, "/api/contacts", token) } + suspend fun importContacts(sessionServerUrl: String, token: String, contacts: List): List { + 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( sessionServerUrl: String, token: String, @@ -104,6 +119,10 @@ class QMaxApi { postNoContent(sessionServerUrl, "/api/chats/clear-history", token, ChatBulkActionRequest(chatIds)) } + suspend fun deleteChats(sessionServerUrl: String, token: String, chatIds: List) { + postNoContent(sessionServerUrl, "/api/chats/delete", token, ChatBulkActionRequest(chatIds)) + } + suspend fun searchMessages(sessionServerUrl: String, token: String, chatId: String, query: String): List { val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString()) return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token) diff --git a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt index f231c62..683abb6 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt @@ -21,6 +21,7 @@ import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto import xyz.kusoft.qmax.core.model.MessageDeletedDto 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.network.QMaxHttpException import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient @@ -420,6 +421,64 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { loadChats() } + fun reportContactsPermissionDenied() { + state.value = state.value.copy( + contactsLoading = false, + contactsError = "Разрешите QMAX доступ к контактам телефона и обновите список" + ) + } + + fun importPhoneContacts(contacts: List) = 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) { saveCurrentDraft() chatSearchJob?.cancel() @@ -550,6 +609,17 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { 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) { if (!showLoading && state.value.sendingMessage) { return@launchLoading diff --git a/pymax-worker/src/server.py b/pymax-worker/src/server.py index ca9eea0..7770551 100644 --- a/pymax-worker/src/server.py +++ b/pymax-worker/src/server.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import base64 +import json import mimetypes import os import pathlib @@ -18,12 +19,14 @@ from typing import Any import aiohttp from aiohttp import web from pymax import Client, ExtraConfig, File, Photo, SyncOverrides, Video +from pymax.types import ContactInfo PORT = int(os.environ.get("PORT", "3002")) 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_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")) 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] @@ -56,6 +59,11 @@ def normalize_phone(phone: str) -> str: 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: text = (error or "").lower() 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")) -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) 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: @@ -265,6 +279,7 @@ def contact_result(contact: Any, me_id: int, external_chat_id: Any | None = None "avatarUrl": user_avatar_url(contact), "phoneNumber": first_text(data.get("phone")), "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" -def is_direct_dialog(chat: Any) -> bool: - return value_name(getattr(chat, "type", "")).upper() == "DIALOG" - - def normalize_attachment_kind(att: Any) -> str: data = dump_model(att) 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) if me_id is None: return json_response({"success": False, "error": "MAX profile has no user id."}, status=503) - - by_user_id: dict[str, dict[str, Any]] = {} - for contact in client.contacts: - normalized = contact_result(contact, me_id) if contact is not None else None - if normalized is not None: - by_user_id[normalized["userId"]] = normalized - - chats = await fetch_chat_pages(client, CHAT_FETCH_LIMIT) - direct_chats = [chat for chat in chats if is_direct_dialog(chat)] - user_map = await build_user_map(client, direct_chats) - for chat in direct_chats: - contact = chat_other_user(chat, user_map, me_id) - normalized = contact_result(contact, me_id, getattr(chat, "id", None)) if contact is not None else None - if normalized is not None: - by_user_id.setdefault(normalized["userId"], normalized) - - result = list(by_user_id.values()) + matched_ids = load_phone_contact_ids() + if not matched_ids: + return json_response([]) + users = await client.get_users(sorted(matched_ids)) + saved_ids = { + coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None)) + for contact in client.contacts + if contact is not None + } + result = [ + normalized + for contact in users + if (normalized := contact_result( + contact, + me_id, + is_saved_contact=coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None)) in saved_ids, + )) is not None + ] result.sort(key=lambda item: item["displayName"].casefold()) 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 async def chat_history(request: web.Request) -> web.Response: 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."}) +@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 async def send_text(request: web.Request) -> web.Response: data = await read_json(request) @@ -1083,13 +1232,16 @@ def create_app() -> web.Application: app.router.add_get("/snapshot", snapshot) app.router.add_get("/updates", updates) 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/resolve-url", resolve_chat_url) app.router.add_post("/channels/search", channels_search) app.router.add_post("/channels/join", channels_join) app.router.add_post("/chat/presence", chat_presence) 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/attachment", send_attachment) app.router.add_post("/message/edit", disabled_action) diff --git a/server/QMax.Api/Contracts/ChatContracts.cs b/server/QMax.Api/Contracts/ChatContracts.cs index d6fac37..7f8380b 100644 --- a/server/QMax.Api/Contracts/ChatContracts.cs +++ b/server/QMax.Api/Contracts/ChatContracts.cs @@ -59,4 +59,9 @@ public sealed record ContactDto( string DisplayName, string? AvatarUrl, string? PhoneNumber, - string? Status); + string? Status, + bool IsSavedContact); + +public sealed record PhoneContactDto(string PhoneNumber, string FirstName, string? LastName = null); +public sealed record ImportPhoneContactsRequest(IReadOnlyList Contacts); +public sealed record ContactUserRequest(string UserId); diff --git a/server/QMax.Api/Controllers/ChatsController.cs b/server/QMax.Api/Controllers/ChatsController.cs index 530a4d7..e242369 100644 --- a/server/QMax.Api/Controllers/ChatsController.cs +++ b/server/QMax.Api/Controllers/ChatsController.cs @@ -51,8 +51,18 @@ public sealed class ChatsController( Kind = ChatKind.MaxDialog }; 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); return projection.ToDto(chat); @@ -274,7 +284,7 @@ public sealed class ChatsController( } [HttpPost("delete")] - public IActionResult DeleteChats(ChatBulkActionRequest request) + public async Task DeleteChats(ChatBulkActionRequest request, CancellationToken cancellationToken) { var chatIds = request.ChatIds.Distinct().ToArray(); if (chatIds.Length == 0) @@ -282,7 +292,43 @@ public sealed class ChatsController( 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")] diff --git a/server/QMax.Api/Controllers/ContactsController.cs b/server/QMax.Api/Controllers/ContactsController.cs index cdfcf00..3e30703 100644 --- a/server/QMax.Api/Controllers/ContactsController.cs +++ b/server/QMax.Api/Controllers/ContactsController.cs @@ -21,8 +21,62 @@ public sealed class ContactsController(IMaxBridgeClient maxBridge) : ControllerB contact.DisplayName, contact.AvatarUrl, contact.PhoneNumber, - contact.Status)) + contact.Status, + contact.IsSavedContact)) .OrderBy(contact => contact.DisplayName, StringComparer.CurrentCultureIgnoreCase) .ToArray(); } + + [HttpPost("import")] + public async Task>> 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> 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 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); } diff --git a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs index 2c5cf9f..3f5f017 100644 --- a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs @@ -11,6 +11,18 @@ public interface IMaxBridgeClient { return Task.FromResult>(Array.Empty()); } + Task> ImportContactsAsync(IReadOnlyList contacts, CancellationToken cancellationToken) + { + return Task.FromResult>(Array.Empty()); + } + Task AddContactAsync(string userId, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + Task RemoveContactAsync(string userId, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(false, "Contact removal is not supported.")); + } Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken); Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken); Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken); diff --git a/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs b/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs index 16c90d9..abe1648 100644 --- a/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs +++ b/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs @@ -80,4 +80,7 @@ public sealed record MaxContact( string DisplayName, string? AvatarUrl, string? PhoneNumber, - string? Status); + string? Status, + bool IsSavedContact = false); + +public sealed record MaxPhoneContact(string PhoneNumber, string FirstName, string? LastName); diff --git a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs index 3011249..25bf91d 100644 --- a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs @@ -48,10 +48,25 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient public Task> FetchContactsAsync(CancellationToken cancellationToken) { return Task.FromResult>([ - 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> ImportContactsAsync(IReadOnlyList contacts, CancellationToken cancellationToken) + { + return FetchContactsAsync(cancellationToken); + } + + public async Task AddContactAsync(string userId, CancellationToken cancellationToken) + { + return (await FetchContactsAsync(cancellationToken)).FirstOrDefault(); + } + + public Task RemoveContactAsync(string userId, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId); @@ -75,9 +90,7 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { - return Task.FromResult(new MaxActionResult( - false, - "Chat deletion is disabled because MAX does not remove chats from the web client.")); + return Task.FromResult(new MaxActionResult(true, null)); } public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) diff --git a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs index 0994b14..7c0570f 100644 --- a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs @@ -49,6 +49,29 @@ public sealed class WorkerMaxBridgeClient( ?? Array.Empty(); } + public async Task> ImportContactsAsync( + IReadOnlyList contacts, + CancellationToken cancellationToken) + { + return await SendAsync>( + HttpMethod.Post, + "/contacts/import", + new { contacts }, + cancellationToken) + ?? Array.Empty(); + } + + public async Task AddContactAsync(string userId, CancellationToken cancellationToken) + { + return await SendAsync(HttpMethod.Post, "/contacts/add", new { userId }, cancellationToken); + } + + public async Task RemoveContactAsync(string userId, CancellationToken cancellationToken) + { + return await SendAsync(HttpMethod.Post, "/contacts/remove", new { userId }, cancellationToken) + ?? new MaxActionResult(false, "Worker returned an empty contact removal result."); + } + public async Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return await SendAsync(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."); } - public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + public async Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { - return Task.FromResult(new MaxActionResult( - false, - "Chat deletion is disabled because MAX does not remove chats from the web client.")); + return await SendAsync( + HttpMethod.Post, + "/chat/delete", + new { externalChatId, chatUrl }, + cancellationToken) + ?? new MaxActionResult(false, "Worker returned an empty chat deletion result."); } public async Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) diff --git a/server/QMax.Api/Services/MaxOutboxService.cs b/server/QMax.Api/Services/MaxOutboxService.cs index 116f3ec..693ad16 100644 --- a/server/QMax.Api/Services/MaxOutboxService.cs +++ b/server/QMax.Api/Services/MaxOutboxService.cs @@ -125,9 +125,10 @@ public sealed class MaxOutboxService( chat, (externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken), cancellationToken), - ChatPendingMaxAction.DeleteChat => new MaxActionResult( - false, - "Chat deletion is disabled because MAX does not remove chats from the web client."), + ChatPendingMaxAction.DeleteChat => await ApplyMaxChatActionAsync( + chat, + (externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken), + cancellationToken), _ => new MaxActionResult(false, $"Unsupported chat action: {pendingAction.Value}.") }; } diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index ba75963..a81f578 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -125,6 +125,14 @@ public sealed class ApiSmokeTests : IDisposable var contact = Assert.Single(contacts!); Assert.Equal("mock-direct", contact.ExternalChatId); 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(JsonOptions); + Assert.Single(imported!); var response = await client.PostAsJsonAsync( "/api/chats/direct", @@ -134,6 +142,14 @@ public sealed class ApiSmokeTests : IDisposable Assert.NotNull(chat); Assert.Equal(contact.ExternalChatId, chat!.ExternalId); 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("/api/chats", JsonOptions); + Assert.DoesNotContain(chats!, candidate => candidate.Id == chat.Id); } [Fact]