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/**
storage/
!server/QMax.Api/Infrastructure/Storage/
!server/QMax.Api/Infrastructure/Storage/**
secrets/
.env
.env.*
+2 -2
View File
@@ -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()
+71 -5
View File
@@ -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"
};
}
}
+21
View File
@@ -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()
{