Add MAX contacts and direct chat creation

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