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