Add MAX contacts and direct chat creation
This commit is contained in:
@@ -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.*
|
||||
|
||||
@@ -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\"")
|
||||
|
||||
@@ -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<ChatDto>,
|
||||
filteredContacts: List<ContactDto>,
|
||||
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<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)
|
||||
@Composable
|
||||
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.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<ContactDto> {
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ContactDto> {
|
||||
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> {
|
||||
|
||||
@@ -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<ChatDto> = emptyList(),
|
||||
val contacts: List<ContactDto> = emptyList(),
|
||||
val contactsLoading: Boolean = false,
|
||||
val contactsError: String? = null,
|
||||
val selectedChat: ChatDto? = null,
|
||||
val messages: 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,
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -52,3 +52,11 @@ public sealed record CreateDirectChatRequest(string ExternalChatId, string Title
|
||||
public sealed record ChatBulkActionRequest(IReadOnlyList<Guid> 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);
|
||||
|
||||
@@ -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<MaxBrowserSnapshot> GetSnapshotAsync(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<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
|
||||
Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -45,6 +45,13 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
|
||||
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)
|
||||
{
|
||||
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.");
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<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]
|
||||
public async Task ChatListReturnsAllChatsBeyondLegacyLimit()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user