Compare commits

...

18 Commits

Author SHA1 Message Date
sevenhill 0a42e6f860 Add channel search and subscription 2026-07-07 22:09:18 +03:00
sevenhill b2766ebbe9 Open chats at latest message 2026-07-07 14:35:26 +03:00
sevenhill af84343e19 Switch MAX bridge to PyMax only 2026-07-07 14:18:49 +03:00
sevenhill 8efeff334a Switch server bridge to PyMax 2026-07-07 00:05:47 +03:00
sevenhill 81b20dcaec Add PyMax worker POC 2026-07-06 23:32:22 +03:00
sevenhill 183ec8d90f Disable chat deletion 2026-07-06 22:46:55 +03:00
sevenhill 52ae43c25d Require real MAX chat removal 2026-07-06 21:53:03 +03:00
sevenhill 7aae5acda3 Fix async chat actions and channel sync 2026-07-06 21:38:35 +03:00
sevenhill 84bf7e96c9 Update bottom navigation tabs 2026-07-06 15:43:03 +03:00
sevenhill 525965ea38 Recover sparse MAX sticker captures 2026-07-06 15:12:33 +03:00
sevenhill 4dfcff0301 Hydrate generic media previews before notifying 2026-07-06 14:57:59 +03:00
sevenhill df429d5c43 Prevent empty MAX media placeholders 2026-07-06 14:40:25 +03:00
sevenhill 057d4346fe Reject wallpaper captures for stickers 2026-07-06 14:12:50 +03:00
sevenhill d18eefdda8 Fallback to sticker element screenshot 2026-07-06 14:00:37 +03:00
sevenhill 342fdae404 Bind DOM media downloads to chat context 2026-07-06 13:47:32 +03:00
sevenhill e50b99bcd2 Fix sticker capture and rendering 2026-07-06 13:37:07 +03:00
sevenhill 30361370da Fix media notifications and photo bubbles 2026-07-06 13:17:47 +03:00
sevenhill 61d7aae7eb Fix media placeholders and image cache 2026-07-06 09:10:31 +03:00
36 changed files with 2686 additions and 7266 deletions
+12 -17
View File
@@ -1,11 +1,11 @@
# QMAX # QMAX
QMAX is a private Android messenger client backed by a personal bridge server. The Android app talks to your QMAX server, and the server keeps a browser session for `https://web.max.ru`. QMAX is a private Android messenger client backed by a personal bridge server. The Android app talks to your QMAX server, and the server uses PyMax as the only MAX bridge.
Current shape: Current shape:
- `server/QMax.Api` - ASP.NET Core API, SQLite cache, JWT pairing auth, SignalR hub, attachment storage, APK update catalog. - `server/QMax.Api` - ASP.NET Core API, SQLite cache, JWT pairing auth, SignalR hub, attachment storage, APK update catalog.
- `worker` - Node/Playwright worker with a persistent MAX Web browser profile. - `pymax-worker` - Python/PyMax worker with a persistent MAX mobile API session.
- `android` - Kotlin + Jetpack Compose Android client pointed at `https://qmax.kusoft.xyz`. - `android` - Kotlin + Jetpack Compose Android client pointed at `https://qmax.kusoft.xyz`.
- `deploy` - Docker Compose + Caddy for Raspberry Pi 5. - `deploy` - Docker Compose + Caddy for Raspberry Pi 5.
@@ -13,11 +13,9 @@ Current shape:
```powershell ```powershell
dotnet test QMax.slnx dotnet test QMax.slnx
python -m py_compile pymax-worker/src/server.py
cd android cd android
.\gradlew.bat :app:assembleDebug :app:assembleRelease --console=plain --no-daemon .\gradlew.bat :app:assembleDebug :app:assembleRelease --console=plain --no-daemon
cd ..\worker
npm.cmd install
node --check src/server.js
``` ```
## Raspberry Pi Deployment ## Raspberry Pi Deployment
@@ -47,7 +45,7 @@ Set strong values in `.env`:
- `QMAX_JWT_SECRET` - at least 32 random characters. - `QMAX_JWT_SECRET` - at least 32 random characters.
- `QMAX_PAIRING_CODE` - one-time-ish pairing password for your Android client and `/admin/max`. - `QMAX_PAIRING_CODE` - one-time-ish pairing password for your Android client and `/admin/max`.
- `QMAX_MAX_PHONE_NUMBER` - the phone number used for MAX Web login. - `QMAX_MAX_PHONE_NUMBER` - the phone number linked to the MAX account used by PyMax.
Secrets must stay in `.env`, not in git. Secrets must stay in `.env`, not in git.
@@ -59,9 +57,9 @@ Open:
https://qmax.kusoft.xyz/admin/max?pairingCode=YOUR_PAIRING_CODE https://qmax.kusoft.xyz/admin/max?pairingCode=YOUR_PAIRING_CODE
``` ```
Use **Start phone login**. If MAX shows a robot check, solve it manually on this page using the screenshot plus click/type forms. When MAX sends the confirmation code, submit it on the same page or from the Android app MAX panel. Use **Start phone login**. When MAX sends the confirmation code, submit it on the same page or from the Android app settings.
The worker stores browser state in the `qmax-max-profile` Docker volume, so the MAX session should survive restarts. The worker stores PyMax session state in the `qmax-pymax-session` Docker volume, so the MAX session should survive restarts until MAX expires the login token.
## Android Pairing ## Android Pairing
@@ -132,14 +130,11 @@ The Android app checks this manifest, compares semantic versions, downloads the
## Current MAX Mapping Status ## Current MAX Mapping Status
The deployed worker is authorized in MAX Web and currently maps: The deployed worker is authorized through PyMax and currently maps:
- chat list extraction from the left MAX Web dialog list; - chat list and message history through PyMax;
- visible chat history extraction when Android opens a dialog; - text sending through PyMax;
- text sending through the MAX Web composer; - attachment upload through PyMax file/photo/video models;
- attachment upload through the MAX Web file chooser; - image, video, file and voice attachment projection through the API;
- image, video, file and voice attachment projection from MAX Web;
- Android image attachment caching with `.part` downloads before a file is shown from local storage; - Android image attachment caching with `.part` downloads before a file is shown from local storage;
- manual browser inspection endpoints for future selector changes. - explicit session status and phone-code re-login from Android settings.
The bridge uses DOM selectors in `worker/src/server.js`, so MAX Web UI changes may require a worker redeploy without changing the Android app contract.
@@ -3,6 +3,7 @@ package xyz.kusoft.qmax
import android.Manifest import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.media.MediaRecorder import android.media.MediaRecorder
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@@ -66,7 +67,7 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Campaign
import androidx.compose.material.icons.filled.Chat import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.KeyboardArrowUp
@@ -76,7 +77,6 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
@@ -157,11 +157,15 @@ import java.time.OffsetDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
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.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
import xyz.kusoft.qmax.core.model.MessageDto import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.QMaxSession import xyz.kusoft.qmax.core.model.QMaxSession
import xyz.kusoft.qmax.ui.QMaxUiState import xyz.kusoft.qmax.ui.QMaxUiState
@@ -294,7 +298,7 @@ private fun LoginScreen(vm: QMaxViewModel) {
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text("QMAX", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = QMaxBlue) Text("QMAX", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = QMaxBlue)
Text("MAX Web в привычном мессенджере", color = QMaxMuted, modifier = Modifier.padding(top = 6.dp)) Text("MAX в привычном мессенджере", color = QMaxMuted, modifier = Modifier.padding(top = 6.dp))
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
OutlinedTextField( OutlinedTextField(
value = state.serverUrl, value = state.serverUrl,
@@ -322,40 +326,72 @@ private fun LoginScreen(vm: QMaxViewModel) {
} }
private enum class ChatBulkUiAction { private enum class ChatBulkUiAction {
ClearHistory, ClearHistory
Delete }
private enum class MainTab {
Chats,
Contacts,
Channels,
Settings
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ChatListScreen(vm: QMaxViewModel) { private fun ChatListScreen(vm: QMaxViewModel) {
val state by vm.state val state by vm.state
var activeTab by rememberSaveable { mutableStateOf(MainTab.Chats) }
var menuOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) }
var selectionMenuOpen by remember { mutableStateOf(false) } var selectionMenuOpen by remember { mutableStateOf(false) }
var pendingBulkAction by remember { mutableStateOf<ChatBulkUiAction?>(null) } var pendingBulkAction by remember { mutableStateOf<ChatBulkUiAction?>(null) }
var searchMode by remember { mutableStateOf(false) } var searchMode by remember { mutableStateOf(false) }
var newChatOpen by remember { mutableStateOf(false) } var newChatOpen by remember { mutableStateOf(false) }
val filteredChats = remember(state.chats, state.searchQuery) { val filteredChats = remember(state.chats, state.searchQuery, activeTab) {
val query = state.searchQuery.trim() val query = state.searchQuery.trim()
val source = when (activeTab) {
MainTab.Chats -> state.chats.filterNot { it.isChannel() }
MainTab.Channels -> state.chats.filter { it.isChannel() }
MainTab.Contacts,
MainTab.Settings -> emptyList()
}
if (query.isBlank()) { if (query.isBlank()) {
state.chats source
} else { } else {
state.chats.filter { source.filter {
it.title.contains(query, ignoreCase = true) || it.title.contains(query, ignoreCase = true) ||
(it.lastMessagePreview?.contains(query, ignoreCase = true) == true) (it.lastMessagePreview?.contains(query, ignoreCase = true) == true)
} }
} }
} }
LaunchedEffect(activeTab, state.searchQuery) {
if (activeTab == MainTab.Channels) {
vm.searchMaxChannels(state.searchQuery)
} else {
vm.searchMaxChannels("")
}
}
TelegramChatListContent( TelegramChatListContent(
state = state, state = state,
filteredChats = filteredChats, filteredChats = filteredChats,
activeTab = activeTab,
menuOpen = menuOpen, menuOpen = menuOpen,
onMenuOpenChange = { menuOpen = it }, onMenuOpenChange = { menuOpen = it },
onRefresh = vm::loadChats, onRefresh = vm::loadChats,
onCheckUpdates = vm::checkArgusUpdate, onCheckUpdates = vm::checkArgusUpdate,
onRefreshMaxStatus = vm::loadMaxStatus,
onStartMaxLogin = vm::startMaxLogin,
onMaxCode = vm::updateMaxCode,
onSubmitMaxCode = vm::submitMaxCode,
onLogout = vm::logout, onLogout = vm::logout,
onSearch = vm::updateSearchQuery, onSearch = vm::updateSearchQuery,
onSubscribeChannel = vm::subscribeMaxChannel,
onTabSelected = { tab ->
activeTab = tab
vm.updateSearchQuery("")
vm.clearChatSelection()
},
onOpenChat = vm::openChat, onOpenChat = vm::openChat,
onNewChat = { newChatOpen = true }, onNewChat = { newChatOpen = true },
onCacheAvatar = vm::cacheAvatar, onCacheAvatar = vm::cacheAvatar,
@@ -367,10 +403,6 @@ private fun ChatListScreen(vm: QMaxViewModel) {
onClearSelectedHistory = { onClearSelectedHistory = {
selectionMenuOpen = false selectionMenuOpen = false
pendingBulkAction = ChatBulkUiAction.ClearHistory pendingBulkAction = ChatBulkUiAction.ClearHistory
},
onDeleteSelectedChats = {
selectionMenuOpen = false
pendingBulkAction = ChatBulkUiAction.Delete
} }
) )
@@ -382,7 +414,6 @@ private fun ChatListScreen(vm: QMaxViewModel) {
Text( Text(
when (action) { when (action) {
ChatBulkUiAction.ClearHistory -> "Очистить историю" ChatBulkUiAction.ClearHistory -> "Очистить историю"
ChatBulkUiAction.Delete -> "Удалить чаты"
} }
) )
}, },
@@ -390,7 +421,6 @@ private fun ChatListScreen(vm: QMaxViewModel) {
Text( Text(
when (action) { when (action) {
ChatBulkUiAction.ClearHistory -> "Очистить историю в выбранных чатах: $selectedCount?" ChatBulkUiAction.ClearHistory -> "Очистить историю в выбранных чатах: $selectedCount?"
ChatBulkUiAction.Delete -> "Удалить выбранные чаты: $selectedCount?"
} }
) )
}, },
@@ -400,14 +430,12 @@ private fun ChatListScreen(vm: QMaxViewModel) {
pendingBulkAction = null pendingBulkAction = null
when (action) { when (action) {
ChatBulkUiAction.ClearHistory -> vm.clearSelectedChatHistories() ChatBulkUiAction.ClearHistory -> vm.clearSelectedChatHistories()
ChatBulkUiAction.Delete -> vm.deleteSelectedChats()
} }
} }
) { ) {
Text( Text(
when (action) { when (action) {
ChatBulkUiAction.ClearHistory -> "Очистить" ChatBulkUiAction.ClearHistory -> "Очистить"
ChatBulkUiAction.Delete -> "Удалить"
} }
) )
} }
@@ -578,12 +606,19 @@ private fun ChatListScreen(vm: QMaxViewModel) {
private fun TelegramChatListContent( private fun TelegramChatListContent(
state: QMaxUiState, state: QMaxUiState,
filteredChats: List<ChatDto>, filteredChats: List<ChatDto>,
activeTab: MainTab,
menuOpen: Boolean, menuOpen: Boolean,
onMenuOpenChange: (Boolean) -> Unit, onMenuOpenChange: (Boolean) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onCheckUpdates: () -> Unit, onCheckUpdates: () -> Unit,
onRefreshMaxStatus: () -> Unit,
onStartMaxLogin: () -> Unit,
onMaxCode: (String) -> Unit,
onSubmitMaxCode: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onSubscribeChannel: (MaxChannelSearchResultDto) -> Unit,
onTabSelected: (MainTab) -> Unit,
onOpenChat: (ChatDto) -> Unit, onOpenChat: (ChatDto) -> Unit,
onNewChat: () -> Unit, onNewChat: () -> Unit,
onCacheAvatar: (String) -> Unit, onCacheAvatar: (String) -> Unit,
@@ -592,10 +627,25 @@ private fun TelegramChatListContent(
onClearSelection: () -> Unit, onClearSelection: () -> Unit,
onToggleChatSelection: (String) -> Unit, onToggleChatSelection: (String) -> Unit,
onBeginChatSelection: (String) -> Unit, onBeginChatSelection: (String) -> Unit,
onClearSelectedHistory: () -> Unit, onClearSelectedHistory: () -> Unit
onDeleteSelectedChats: () -> Unit
) { ) {
val selectedCount = state.selectedChatIds.size val selectedCount = state.selectedChatIds.size
val listTab = activeTab != MainTab.Settings
val showChannelSearch = activeTab == MainTab.Channels &&
state.searchQuery.trim().isNotBlank() &&
(state.channelSearchLoading || !state.channelSearchError.isNullOrBlank() || state.channelSearchResults.any { !it.isSubscribed })
val headerTitle = when (activeTab) {
MainTab.Chats -> if (state.chatListConnectionIssue) "Соединение..." else "QMAX"
MainTab.Contacts -> "Контакты"
MainTab.Channels -> "Каналы"
MainTab.Settings -> "Настройки"
}
val emptyText = when (activeTab) {
MainTab.Chats -> null
MainTab.Contacts -> "Контактов пока нет"
MainTab.Channels -> "Каналов пока нет"
MainTab.Settings -> null
}
Box(Modifier.fillMaxSize().background(Color.White)) { Box(Modifier.fillMaxSize().background(Color.White)) {
Column( Column(
Modifier Modifier
@@ -609,39 +659,58 @@ private fun TelegramChatListContent(
menuOpen = selectionMenuOpen, menuOpen = selectionMenuOpen,
onMenuOpenChange = onSelectionMenuOpenChange, onMenuOpenChange = onSelectionMenuOpenChange,
onClearSelection = onClearSelection, onClearSelection = onClearSelection,
onClearHistory = onClearSelectedHistory, onClearHistory = onClearSelectedHistory
onDelete = onDeleteSelectedChats
) )
} else { } else {
TelegramLikeHeader( TelegramLikeHeader(
title = if (state.chatListConnectionIssue) "Соединение..." else "QMAX", title = headerTitle,
menuOpen = menuOpen, menuOpen = menuOpen,
onMenuOpenChange = onMenuOpenChange, onMenuOpenChange = onMenuOpenChange,
onRefresh = onRefresh, onRefresh = onRefresh,
onCheckUpdates = onCheckUpdates,
onLogout = onLogout onLogout = onLogout
) )
} }
ChatListSearchBar(query = state.searchQuery, onQuery = onSearch) ErrorLine(state.error)
state.updateMessage?.let { if (activeTab != MainTab.Settings && state.maxStatus?.isAuthorized == false) {
Text( MaxSessionExpiredBanner(onClick = { onTabSelected(MainTab.Settings) })
it, }
color = QMaxMuted, if (activeTab == MainTab.Settings) {
modifier = Modifier.fillMaxWidth().padding(horizontal = 28.dp, vertical = 6.dp) SettingsTabContent(
state = state,
onCheckUpdates = onCheckUpdates,
onRefreshMaxStatus = onRefreshMaxStatus,
onStartMaxLogin = onStartMaxLogin,
onMaxCode = onMaxCode,
onSubmitMaxCode = onSubmitMaxCode,
onRefresh = onRefresh,
onLogout = onLogout
)
} else {
ChatListSearchBar(
query = state.searchQuery,
placeholder = if (activeTab == MainTab.Channels) "Поиск каналов" else "Поиск чатов",
onQuery = onSearch
) )
} }
ErrorLine(state.error) if (listTab && filteredChats.isEmpty() && !showChannelSearch) {
if (filteredChats.isEmpty()) {
if (state.searchQuery.isNotBlank()) { if (state.searchQuery.isNotBlank()) {
EmptyState("Ничего не найдено") EmptyState("Ничего не найдено")
} else { } else {
Spacer(Modifier.fillMaxSize()) emptyText?.let { EmptyState(it) } ?: Spacer(Modifier.fillMaxSize())
} }
} else { } else if (listTab) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 118.dp) contentPadding = PaddingValues(bottom = 118.dp)
) { ) {
if (showChannelSearch) {
item {
ChannelSearchResults(
state = state,
onSubscribeChannel = onSubscribeChannel
)
}
}
items(filteredChats, key = { it.id }) { chat -> items(filteredChats, key = { it.id }) { chat ->
TelegramChatRow( TelegramChatRow(
chat = chat, chat = chat,
@@ -665,19 +734,24 @@ private fun TelegramChatListContent(
} }
} }
ChatListFloatingActions( if (activeTab == MainTab.Chats) {
modifier = Modifier ChatListFloatingActions(
.align(Alignment.BottomEnd) modifier = Modifier
.navigationBarsPadding() .align(Alignment.BottomEnd)
.padding(end = 22.dp, bottom = 96.dp), .navigationBarsPadding()
onNewChat = onNewChat .padding(end = 22.dp, bottom = 96.dp),
) onNewChat = onNewChat
)
}
TelegramBottomNavigation( TelegramBottomNavigation(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.navigationBarsPadding() .navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 10.dp), .padding(horizontal = 28.dp, vertical = 10.dp),
unreadCount = state.chats.sumOf { it.unreadCount } activeTab = activeTab,
chatUnreadCount = state.chats.filterNot { it.isChannel() }.sumOf { it.unreadCount },
channelUnreadCount = state.chats.filter { it.isChannel() }.sumOf { it.unreadCount },
onTabSelected = onTabSelected
) )
} }
} }
@@ -688,7 +762,6 @@ private fun TelegramLikeHeader(
menuOpen: Boolean, menuOpen: Boolean,
onMenuOpenChange: (Boolean) -> Unit, onMenuOpenChange: (Boolean) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onCheckUpdates: () -> Unit,
onLogout: () -> Unit onLogout: () -> Unit
) { ) {
Row( Row(
@@ -721,13 +794,6 @@ private fun TelegramLikeHeader(
onRefresh() onRefresh()
} }
) )
DropdownMenuItem(
text = { Text("Проверить обновления") },
onClick = {
onMenuOpenChange(false)
onCheckUpdates()
}
)
DropdownMenuItem( DropdownMenuItem(
text = { Text("Выйти") }, text = { Text("Выйти") },
onClick = { onClick = {
@@ -780,8 +846,7 @@ private fun ChatSelectionHeader(
menuOpen: Boolean, menuOpen: Boolean,
onMenuOpenChange: (Boolean) -> Unit, onMenuOpenChange: (Boolean) -> Unit,
onClearSelection: () -> Unit, onClearSelection: () -> Unit,
onClearHistory: () -> Unit, onClearHistory: () -> Unit
onDelete: () -> Unit
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -830,27 +895,13 @@ private fun ChatSelectionHeader(
onClearHistory() onClearHistory()
} }
) )
DropdownMenuItem(
text = { Text("Удалить") },
leadingIcon = {
Icon(
Icons.Filled.Delete,
contentDescription = null,
tint = Color(0xFFD84343)
)
},
onClick = {
onMenuOpenChange(false)
onDelete()
}
)
} }
} }
} }
} }
@Composable @Composable
private fun ChatListSearchBar(query: String, onQuery: (String) -> Unit) { private fun ChatListSearchBar(query: String, placeholder: String, onQuery: (String) -> Unit) {
Surface( Surface(
color = Color(0xFFF1F1F3), color = Color(0xFFF1F1F3),
shape = RoundedCornerShape(30.dp), shape = RoundedCornerShape(30.dp),
@@ -868,7 +919,7 @@ private fun ChatListSearchBar(query: String, onQuery: (String) -> Unit) {
value = query, value = query,
onValueChange = onQuery, onValueChange = onQuery,
placeholder = { placeholder = {
Text("Поиск чатов", color = Color(0xFF8A8E93), style = MaterialTheme.typography.titleMedium) Text(placeholder, color = Color(0xFF8A8E93), style = MaterialTheme.typography.titleMedium)
}, },
singleLine = true, singleLine = true,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
@@ -1018,12 +1069,6 @@ private fun TelegramChatRow(
@Composable @Composable
private fun ChatListFloatingActions(modifier: Modifier = Modifier, onNewChat: () -> Unit) { private fun ChatListFloatingActions(modifier: Modifier = Modifier, onNewChat: () -> Unit) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Surface(color = Color.White, shape = CircleShape, shadowElevation = 4.dp) {
IconButton(onClick = onNewChat, modifier = Modifier.size(52.dp)) {
Icon(Icons.Filled.PhotoCamera, contentDescription = "Камера", tint = QMaxText, modifier = Modifier.size(28.dp))
}
}
Spacer(Modifier.height(12.dp))
FloatingActionButton( FloatingActionButton(
onClick = onNewChat, onClick = onNewChat,
containerColor = Color(0xFF20BFA9), containerColor = Color(0xFF20BFA9),
@@ -1037,7 +1082,232 @@ private fun ChatListFloatingActions(modifier: Modifier = Modifier, onNewChat: ()
} }
@Composable @Composable
private fun TelegramBottomNavigation(modifier: Modifier = Modifier, unreadCount: Int) { private fun MaxSessionExpiredBanner(onClick: () -> Unit) {
Surface(
color = Color(0xFFFFF3E0),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Column(Modifier.padding(horizontal = 20.dp, vertical = 10.dp)) {
Text("Сессия MAX закончилась", fontWeight = FontWeight.SemiBold, color = Color(0xFF9A4D00))
Text("Откройте настройки, получите новый код и отдайте его QMAX.", color = QMaxMuted, style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun SettingsTabContent(
state: QMaxUiState,
onCheckUpdates: () -> Unit,
onRefreshMaxStatus: () -> Unit,
onStartMaxLogin: () -> Unit,
onMaxCode: (String) -> Unit,
onSubmitMaxCode: () -> Unit,
onRefresh: () -> Unit,
onLogout: () -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 28.dp, top = 14.dp, end = 28.dp, bottom = 118.dp)
) {
item {
Text("Версия приложения", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = QMaxText)
Spacer(Modifier.height(8.dp))
Text(BuildConfig.VERSION_NAME, style = MaterialTheme.typography.bodyLarge, color = QMaxMuted)
Spacer(Modifier.height(12.dp))
Button(onClick = onCheckUpdates, enabled = !state.loading) {
Text(if (state.loading) "Проверяю..." else "Проверить обновление")
}
state.updateMessage?.let {
Spacer(Modifier.height(10.dp))
Text(it, color = QMaxMuted, style = MaterialTheme.typography.bodyMedium)
}
Spacer(Modifier.height(22.dp))
HorizontalDivider(color = Color(0xFFE7EEF3))
}
item {
val status = state.maxStatus
val loginAvailable = status?.isAuthorized == false
val waitingForCode = isMaxLoginWaitingForCode(status)
val requestCodeEnabled = loginAvailable && !waitingForCode && !state.loading
val submitCodeEnabled = loginAvailable && state.maxCode.trim().isNotBlank() && !state.loading
Spacer(Modifier.height(22.dp))
Text("MAX", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = QMaxText)
Spacer(Modifier.height(8.dp))
Text(
maxStatusTitle(status),
style = MaterialTheme.typography.bodyLarge,
color = if (loginAvailable) Color(0xFFC62828) else QMaxMuted
)
Text(
maxStatusHint(status),
style = MaterialTheme.typography.bodyMedium,
color = QMaxMuted,
modifier = Modifier.padding(top = 4.dp)
)
status?.lastError?.takeIf { it.isNotBlank() }?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodyMedium, color = Color(0xFFC62828))
}
Spacer(Modifier.height(12.dp))
Column {
Button(
onClick = onRefreshMaxStatus,
enabled = !state.loading,
modifier = Modifier.fillMaxWidth()
) {
Text("Обновить статус")
}
Spacer(Modifier.height(8.dp))
Button(
onClick = onStartMaxLogin,
enabled = requestCodeEnabled,
modifier = Modifier.fillMaxWidth()
) {
Text(if (waitingForCode) "Код запрошен" else "Получить новый код")
}
}
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = state.maxCode,
onValueChange = onMaxCode,
enabled = loginAvailable && !state.loading,
label = { Text("Новый код MAX") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
Button(
onClick = onSubmitMaxCode,
enabled = submitCodeEnabled,
modifier = Modifier.fillMaxWidth()
) {
Text("Отдать код")
}
Spacer(Modifier.height(22.dp))
HorizontalDivider(color = Color(0xFFE7EEF3))
Spacer(Modifier.height(22.dp))
Button(onClick = onRefresh, enabled = !state.loading) {
Text("Обновить чаты")
}
Spacer(Modifier.height(8.dp))
TextButton(onClick = onLogout) {
Text("Выйти")
}
}
}
}
private fun isMaxLoginWaitingForCode(status: MaxBridgeStatusDto?): Boolean {
return status?.loginStage.equals("Code", ignoreCase = true) ||
status?.status.equals("Code", ignoreCase = true)
}
private fun maxStatusTitle(status: MaxBridgeStatusDto?): String {
return when {
status == null -> "Статус неизвестен"
status.isAuthorized -> "Сессия MAX активна"
status.loginStage.equals("Expired", ignoreCase = true) ||
status.status.equals("SessionExpired", ignoreCase = true) -> "Сессия MAX закончилась"
isMaxLoginWaitingForCode(status) -> "MAX ждёт код подтверждения"
else -> "MAX требует вход"
}
}
private fun maxStatusHint(status: MaxBridgeStatusDto?): String {
return when {
status == null -> "Проверка статуса выполняется автоматически. Можно обновить статус вручную."
status.isAuthorized -> "Получение нового кода будет доступно только после окончания сессии."
isMaxLoginWaitingForCode(status) -> "Введите код из MAX и нажмите «Отдать код»."
else -> "Нажмите «Получить новый код», затем введите код подтверждения."
}
}
@Composable
private fun ChannelSearchResults(
state: QMaxUiState,
onSubscribeChannel: (MaxChannelSearchResultDto) -> Unit
) {
val joinableResults = state.channelSearchResults.filterNot { it.isSubscribed }
Column(Modifier.fillMaxWidth()) {
if (state.channelSearchLoading) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 28.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(color = QMaxBlue, modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(12.dp))
Text("Поиск каналов", color = QMaxMuted, style = MaterialTheme.typography.bodyMedium)
}
}
state.channelSearchError?.takeIf { it.isNotBlank() }?.let {
Text(
it,
color = Color(0xFFC62828),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth().padding(horizontal = 28.dp, vertical = 10.dp)
)
}
joinableResults.forEach { result ->
ChannelSearchRow(result = result, onSubscribe = { onSubscribeChannel(result) })
}
if (joinableResults.isNotEmpty()) {
HorizontalDivider(color = Color(0xFFE7EEF3), modifier = Modifier.padding(start = 88.dp))
}
}
}
@Composable
private fun ChannelSearchRow(result: MaxChannelSearchResultDto, onSubscribe: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(color = Color(0xFFE6F4F1), shape = CircleShape, modifier = Modifier.size(54.dp)) {
Box(contentAlignment = Alignment.Center) {
Text(
result.title.firstOrNull()?.uppercaseChar()?.toString() ?: "#",
color = QMaxBlue,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
}
}
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
result.title,
color = QMaxText,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
result.description?.takeIf { it.isNotBlank() }?.let {
Text(
it,
color = QMaxMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium
)
}
}
Spacer(Modifier.width(10.dp))
Button(onClick = onSubscribe, enabled = !result.isSubscribed) {
Text(if (result.isSubscribed) "В списке" else "Подписаться")
}
}
}
@Composable
private fun TelegramBottomNavigation(
modifier: Modifier = Modifier,
activeTab: MainTab,
chatUnreadCount: Int,
channelUnreadCount: Int,
onTabSelected: (MainTab) -> Unit
) {
Surface( Surface(
color = Color.White, color = Color.White,
shape = RoundedCornerShape(30.dp), shape = RoundedCornerShape(30.dp),
@@ -1050,24 +1320,29 @@ private fun TelegramBottomNavigation(modifier: Modifier = Modifier, unreadCount:
) { ) {
TelegramBottomNavItem( TelegramBottomNavItem(
label = "Чаты", label = "Чаты",
selected = true, selected = activeTab == MainTab.Chats,
icon = { Icon(Icons.Filled.Chat, contentDescription = null) }, icon = { Icon(Icons.Filled.Chat, contentDescription = null) },
badge = unreadCount.takeIf { it > 0 } badge = chatUnreadCount.takeIf { it > 0 },
onClick = { onTabSelected(MainTab.Chats) }
) )
TelegramBottomNavItem( TelegramBottomNavItem(
label = "Контакты", label = "Контакты",
selected = false, selected = activeTab == MainTab.Contacts,
icon = { Icon(Icons.Filled.Person, contentDescription = null) } icon = { Icon(Icons.Filled.Person, contentDescription = null) },
onClick = { onTabSelected(MainTab.Contacts) }
)
TelegramBottomNavItem(
label = "Каналы",
selected = activeTab == MainTab.Channels,
icon = { Icon(Icons.Filled.Campaign, contentDescription = null) },
badge = channelUnreadCount.takeIf { it > 0 },
onClick = { onTabSelected(MainTab.Channels) }
) )
TelegramBottomNavItem( TelegramBottomNavItem(
label = "Настройки", label = "Настройки",
selected = false, selected = activeTab == MainTab.Settings,
icon = { Icon(Icons.Filled.Settings, contentDescription = null) } icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
) onClick = { onTabSelected(MainTab.Settings) }
TelegramBottomNavItem(
label = "Профиль",
selected = false,
icon = { Icon(Icons.Filled.AccountCircle, contentDescription = null) }
) )
} }
} }
@@ -1078,13 +1353,15 @@ private fun RowScope.TelegramBottomNavItem(
label: String, label: String,
selected: Boolean, selected: Boolean,
icon: @Composable () -> Unit, icon: @Composable () -> Unit,
badge: Int? = null badge: Int? = null,
onClick: () -> Unit
) { ) {
val tint = if (selected) Color(0xFF20BFA9) else QMaxText val tint = if (selected) Color(0xFF20BFA9) else QMaxText
Column( Column(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.clip(RoundedCornerShape(26.dp)) .clip(RoundedCornerShape(26.dp))
.clickable(onClick = onClick)
.background(if (selected) Color(0xFFE6FAF5) else Color.Transparent) .background(if (selected) Color(0xFFE6FAF5) else Color.Transparent)
.padding(vertical = 6.dp), .padding(vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
@@ -1120,6 +1397,10 @@ private fun RowScope.TelegramBottomNavItem(
} }
} }
private fun ChatDto.isChannel(): Boolean {
return kind.equals("Channel", ignoreCase = true)
}
@Composable @Composable
private fun NewChatDialog(vm: QMaxViewModel, onDismiss: () -> Unit, onCreate: () -> Unit) { private fun NewChatDialog(vm: QMaxViewModel, onDismiss: () -> Unit, onCreate: () -> Unit) {
val state by vm.state val state by vm.state
@@ -1310,6 +1591,8 @@ private fun cleanChatPreview(value: String?): String? {
private fun isGenericMediaPreview(value: String?): Boolean { private fun isGenericMediaPreview(value: String?): Boolean {
return when (value?.trim()?.lowercase()) { return when (value?.trim()?.lowercase()) {
"\u043c\u0435\u0434\u0438\u0430",
"media",
"\u0444\u043e\u0442\u043e", "\u0444\u043e\u0442\u043e",
"photo", "photo",
"image", "image",
@@ -1485,8 +1768,9 @@ private fun ChatScreen(vm: QMaxViewModel) {
LaunchedEffect(timeline.size) { LaunchedEffect(timeline.size) {
if (timeline.isNotEmpty()) { if (timeline.isNotEmpty()) {
val shouldAutoScroll = previousTimelineSize == 0 || !showScrollToBottom if (previousTimelineSize == 0) {
if (shouldAutoScroll) { listState.scrollToItem(timeline.lastIndex)
} else if (!showScrollToBottom) {
listState.animateScrollToItem(timeline.lastIndex) listState.animateScrollToItem(timeline.lastIndex)
} }
previousTimelineSize = timeline.size previousTimelineSize = timeline.size
@@ -2224,7 +2508,8 @@ private fun MessageRow(
attachments = visualAttachments, attachments = visualAttachments,
session = session, session = session,
vm = vm, vm = vm,
imagePreviewSources = imagePreviewSources imagePreviewSources = imagePreviewSources,
mediaShape = shape
) )
} else { } else {
sortedAttachments.forEach { attachment -> sortedAttachments.forEach { attachment ->
@@ -2232,7 +2517,8 @@ private fun MessageRow(
attachment = attachment, attachment = attachment,
session = session, session = session,
vm = vm, vm = vm,
imagePreviewSources = imagePreviewSources imagePreviewSources = imagePreviewSources,
mediaShape = shape
) )
} }
} }
@@ -2499,6 +2785,7 @@ private fun replyPreviewBody(message: MessageDto): String {
val attachment = message.attachments.firstOrNull() ?: return "Message" val attachment = message.attachments.firstOrNull() ?: return "Message"
return when { return when {
attachment.isEmojiAttachment() -> "Emoji" attachment.isEmojiAttachment() -> "Emoji"
attachment.isStickerAttachment() -> "Sticker"
attachment.isImageAttachment() -> "Photo" attachment.isImageAttachment() -> "Photo"
attachment.isVideoAttachment() -> "Video" attachment.isVideoAttachment() -> "Video"
attachment.isVoiceAttachment() -> "Voice message" attachment.isVoiceAttachment() -> "Voice message"
@@ -2512,13 +2799,16 @@ private fun MediaAlbumGrid(
attachments: List<AttachmentDto>, attachments: List<AttachmentDto>,
session: QMaxSession, session: QMaxSession,
vm: QMaxViewModel, vm: QMaxViewModel,
imagePreviewSources: List<String> imagePreviewSources: List<String>,
mediaShape: RoundedCornerShape
) { ) {
val visible = attachments.take(4) val visible = attachments.take(4)
val extraCount = (attachments.size - visible.size).coerceAtLeast(0) val extraCount = (attachments.size - visible.size).coerceAtLeast(0)
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.clip(mediaShape),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
when (visible.size) { when (visible.size) {
@@ -2593,21 +2883,37 @@ private fun MediaAlbumCell(
val state by vm.state val state by vm.state
val cachedImagePath = state.cachedImagePaths[attachment.id] val cachedImagePath = state.cachedImagePaths[attachment.id]
Box(modifier = modifier.background(Color(0xFFE1E8ED))) { Box(
modifier = modifier
.background(Color(0xFFE1E8ED))
) {
if (attachment.isImageAttachment()) { if (attachment.isImageAttachment()) {
LaunchedEffect(attachment.id, url) { LaunchedEffect(attachment.id, url) {
vm.cacheImage(attachment) vm.cacheImage(attachment)
} }
val model = cachedImagePath?.let(::File) ?: imageRequest(url, session.accessToken) val model = cachedImagePath?.let(::File) ?: imageRequest(url, session.accessToken)
val previewSource = cachedImagePath ?: url val previewSource = cachedImagePath ?: url
var imageLoaded by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
var imageFailed by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
AsyncImage( AsyncImage(
model = model, model = model,
contentDescription = attachment.fileName, contentDescription = attachment.fileName,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clickable { vm.openImage(previewSource, imagePreviewSources) } .clickable { vm.openImage(previewSource, imagePreviewSources) },
onSuccess = {
imageLoaded = true
imageFailed = false
},
onError = {
imageLoaded = false
imageFailed = true
}
) )
if (!imageLoaded) {
MediaImagePlaceholder(failed = imageFailed)
}
} else { } else {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -2648,7 +2954,8 @@ private fun AttachmentView(
attachment: AttachmentDto, attachment: AttachmentDto,
session: QMaxSession, session: QMaxSession,
vm: QMaxViewModel, vm: QMaxViewModel,
imagePreviewSources: List<String> imagePreviewSources: List<String>,
mediaShape: RoundedCornerShape
) { ) {
val url = rememberAttachmentUrl(session, attachment) val url = rememberAttachmentUrl(session, attachment)
val state by vm.state val state by vm.state
@@ -2662,13 +2969,21 @@ private fun AttachmentView(
cachedImagePath, cachedImagePath,
vm vm
) )
attachment.isStickerAttachment() -> StickerAttachment(
attachment,
url,
session.accessToken,
cachedImagePath,
vm
)
attachment.isImageAttachment() -> ImageAttachment( attachment.isImageAttachment() -> ImageAttachment(
attachment, attachment,
url, url,
session.accessToken, session.accessToken,
cachedImagePath, cachedImagePath,
vm, vm,
imagePreviewSources imagePreviewSources,
mediaShape
) )
attachment.isVideoAttachment() -> VideoAttachment(attachment, url, session.accessToken) attachment.isVideoAttachment() -> VideoAttachment(attachment, url, session.accessToken)
attachment.isVoiceAttachment() || attachment.isAudioAttachment() -> VoiceAttachment(attachment, url, session.accessToken) attachment.isVoiceAttachment() || attachment.isAudioAttachment() -> VoiceAttachment(attachment, url, session.accessToken)
@@ -2676,6 +2991,47 @@ private fun AttachmentView(
} }
} }
@Composable
private fun StickerAttachment(
attachment: AttachmentDto,
url: String,
token: String,
cachedImagePath: String?,
vm: QMaxViewModel
) {
LaunchedEffect(attachment.id, url) {
vm.cacheImage(attachment)
}
val model = cachedImagePath?.let(::File) ?: imageRequest(url, token)
var imageLoaded by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
var imageFailed by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
Box(
modifier = Modifier
.size(156.dp)
.clip(RoundedCornerShape(6.dp))
.clickable { vm.openImage(cachedImagePath ?: url, listOf(cachedImagePath ?: url)) },
contentAlignment = Alignment.Center
) {
AsyncImage(
model = model,
contentDescription = attachment.fileName,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
onSuccess = {
imageLoaded = true
imageFailed = false
},
onError = {
imageLoaded = false
imageFailed = true
}
)
if (!imageLoaded) {
MediaImagePlaceholder(failed = imageFailed)
}
}
}
@Composable @Composable
private fun EmojiAttachment( private fun EmojiAttachment(
attachment: AttachmentDto, attachment: AttachmentDto,
@@ -2704,23 +3060,91 @@ private fun ImageAttachment(
token: String, token: String,
cachedImagePath: String?, cachedImagePath: String?,
vm: QMaxViewModel, vm: QMaxViewModel,
imagePreviewSources: List<String> imagePreviewSources: List<String>,
mediaShape: RoundedCornerShape
) { ) {
LaunchedEffect(attachment.id, url) { LaunchedEffect(attachment.id, url) {
vm.cacheImage(attachment) vm.cacheImage(attachment)
} }
val model = cachedImagePath?.let(::File) ?: imageRequest(url, token) val model = cachedImagePath?.let(::File) ?: imageRequest(url, token)
val previewSource = cachedImagePath ?: url val previewSource = cachedImagePath ?: url
AsyncImage( val aspectRatio = rememberImageAspectRatio(cachedImagePath)
model = model, var imageLoaded by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
contentDescription = attachment.fileName, var imageFailed by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
contentScale = ContentScale.Crop, Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1.25f) .aspectRatio(aspectRatio)
.clip(mediaShape)
.background(Color(0xFFE1E8ED)) .background(Color(0xFFE1E8ED))
.clickable { vm.openImage(previewSource, imagePreviewSources) } .clickable { vm.openImage(previewSource, imagePreviewSources) }
) ) {
AsyncImage(
model = model,
contentDescription = attachment.fileName,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
onSuccess = {
imageLoaded = true
imageFailed = false
},
onError = {
imageLoaded = false
imageFailed = true
}
)
if (!imageLoaded) {
MediaImagePlaceholder(failed = imageFailed)
}
}
}
@Composable
private fun rememberImageAspectRatio(cachedImagePath: String?): Float {
var aspectRatio by remember(cachedImagePath) { mutableStateOf(1.25f) }
LaunchedEffect(cachedImagePath) {
val path = cachedImagePath ?: return@LaunchedEffect
val decoded = withContext(Dispatchers.IO) {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(path, options)
if (options.outWidth > 0 && options.outHeight > 0) {
(options.outWidth.toFloat() / options.outHeight.toFloat()).coerceIn(0.58f, 1.9f)
} else {
null
}
}
if (decoded != null) {
aspectRatio = decoded
}
}
return aspectRatio
}
@Composable
private fun MediaImagePlaceholder(failed: Boolean) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFE1E8ED)),
contentAlignment = Alignment.Center
) {
if (failed) {
Icon(
Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = null,
tint = QMaxMuted,
modifier = Modifier.size(28.dp)
)
} else {
CircularProgressIndicator(
color = QMaxBlue,
strokeWidth = 2.dp,
modifier = Modifier.size(24.dp)
)
}
}
} }
@Composable @Composable
@@ -3589,7 +4013,7 @@ private fun imageRequest(url: String, token: String): ImageRequest {
} }
private fun AttachmentDto.isImageAttachment(): Boolean { private fun AttachmentDto.isImageAttachment(): Boolean {
if (isEmojiAttachment()) { if (isEmojiAttachment() || isStickerAttachment()) {
return false return false
} }
return kind.equals("Image", true) || return kind.equals("Image", true) ||
@@ -3604,6 +4028,12 @@ private fun AttachmentDto.isEmojiAttachment(): Boolean {
kind.equals("Emoji", true) kind.equals("Emoji", true)
} }
private fun AttachmentDto.isStickerAttachment(): Boolean {
val normalizedFileName = fileName.lowercase(Locale.ROOT)
return kind.equals("Sticker", true) ||
normalizedFileName.startsWith("max-sticker-")
}
private fun AttachmentDto.isContactAttachment(): Boolean { private fun AttachmentDto.isContactAttachment(): Boolean {
return kind.equals("Contact", true) || return kind.equals("Contact", true) ||
contentType.contains("vcard", ignoreCase = true) || contentType.contains("vcard", ignoreCase = true) ||
@@ -27,6 +27,7 @@ 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.MaxBridgeStatusDto import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
import xyz.kusoft.qmax.core.model.MessageDto import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.QMaxSession import xyz.kusoft.qmax.core.model.QMaxSession
import xyz.kusoft.qmax.core.network.QMaxApi import xyz.kusoft.qmax.core.network.QMaxApi
@@ -102,10 +103,10 @@ class QMaxRepository(
return messageCache.messages(session, chatId) return messageCache.messages(session, chatId)
} }
suspend fun messages(session: QMaxSession, chatId: String): List<MessageDto> { suspend fun messages(session: QMaxSession, chatId: String, sync: Boolean = false): List<MessageDto> {
val messages = withFreshSession(session) { api.messages(it.serverUrl, it.accessToken, chatId) } val messages = withFreshSession(session) { api.messages(it.serverUrl, it.accessToken, chatId, sync) }
messageCache.saveMessages(session, chatId, messages) messageCache.saveMessages(session, chatId, messages)
return messages return messageCache.messages(session, chatId)
} }
suspend fun markChatRead(session: QMaxSession, chatId: String) { suspend fun markChatRead(session: QMaxSession, chatId: String) {
@@ -136,20 +137,6 @@ class QMaxRepository(
} }
} }
suspend fun deleteChats(session: QMaxSession, chatIds: Collection<String>) {
val ids = chatIds.filter(String::isNotBlank).distinct()
if (ids.isEmpty()) {
return
}
messageCache.clearMessages(session, ids)
val updatedChats = cachedChats(session).filterNot { it.id in ids }
messageCache.saveChats(session, orderedChats(updatedChats))
withFreshSession(session) {
api.deleteChats(it.serverUrl, it.accessToken, ids)
}
}
suspend fun searchMessages(session: QMaxSession, chatId: String, query: String): List<MessageDto> { suspend fun searchMessages(session: QMaxSession, chatId: String, query: String): List<MessageDto> {
val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) } val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) }
messages.forEach { messageCache.upsertMessage(session, it) } messages.forEach { messageCache.upsertMessage(session, it) }
@@ -219,7 +206,7 @@ class QMaxRepository(
suspend fun upload(session: QMaxSession, chatId: String, uri: Uri, caption: String?, replyToMessageId: String?): MessageDto { suspend fun upload(session: QMaxSession, chatId: String, uri: Uri, caption: String?, replyToMessageId: String?): MessageDto {
val meta = queryMeta(uri) val meta = queryMeta(uri)
val body = contentUriRequestBody(uri, meta.contentType) val body = contentUriRequestBody(uri, meta.contentType)
return withFreshSession(session) { val message = withFreshSession(session) {
api.uploadAttachment( api.uploadAttachment(
it.serverUrl, it.serverUrl,
it.accessToken, it.accessToken,
@@ -230,7 +217,12 @@ class QMaxRepository(
caption, caption,
replyToMessageId replyToMessageId
) )
}.also { messageCache.upsertMessage(session, it) } }
messageCache.upsertMessage(session, message)
message.attachments.firstOrNull()?.let { attachment ->
cacheUploadedAttachment(session, attachment, uri)
}
return message
} }
suspend fun uploadVoice(session: QMaxSession, chatId: String, file: File, replyToMessageId: String?): MessageDto { suspend fun uploadVoice(session: QMaxSession, chatId: String, file: File, replyToMessageId: String?): MessageDto {
@@ -318,6 +310,18 @@ class QMaxRepository(
return withFreshSession(session) { api.submitMaxCode(it.serverUrl, it.accessToken, code) } return withFreshSession(session) { api.submitMaxCode(it.serverUrl, it.accessToken, code) }
} }
suspend fun searchMaxChannels(session: QMaxSession, query: String): List<MaxChannelSearchResultDto> {
return withFreshSession(session) { api.searchMaxChannels(it.serverUrl, it.accessToken, query) }
}
suspend fun subscribeMaxChannel(session: QMaxSession, link: String): ChatDto {
val chat = withFreshSession(session) { api.subscribeMaxChannel(it.serverUrl, it.accessToken, link) }
val updatedChats = (cachedChats(session).filterNot { it.id == chat.id } + chat)
.let(::orderedChats)
messageCache.saveChats(session, updatedChats)
return chat
}
suspend fun registerCurrentPushDevice(session: QMaxSession): Boolean { suspend fun registerCurrentPushDevice(session: QMaxSession): Boolean {
if (!FirebaseBootstrap.ensureInitialized(context)) { if (!FirebaseBootstrap.ensureInitialized(context)) {
Log.w(PushLogTag, "Firebase is not initialized; push device registration skipped") Log.w(PushLogTag, "Firebase is not initialized; push device registration skipped")
@@ -526,6 +530,22 @@ class QMaxRepository(
} }
} }
private suspend fun cacheUploadedAttachment(session: QMaxSession, attachment: AttachmentDto, uri: Uri) {
runCatching {
attachmentCache.storeFile(session, attachment) { target ->
context.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Cannot open selected file" }
target.outputStream().use { output ->
input.copyTo(output)
}
}
target.length()
}
}.onFailure { error ->
Log.w(RepositoryLogTag, "Cannot prefill attachment cache", error)
}
}
private data class FileMeta(val fileName: String, val contentType: String) private data class FileMeta(val fileName: String, val contentType: String)
private suspend fun <T> withFreshSession( private suspend fun <T> withFreshSession(
@@ -573,6 +593,7 @@ class QMaxRepository(
} }
} }
private const val RepositoryLogTag = "QMaxRepository"
private const val PushLogTag = "QMaxPush" private const val PushLogTag = "QMaxPush"
private data class SharedAttachment( private data class SharedAttachment(
@@ -37,6 +37,36 @@ class AttachmentDiskCache(context: Context) {
} }
} }
suspend fun storeFile(
session: QMaxSession,
attachment: AttachmentDto,
writer: suspend (File) -> Long
): File {
return withContext(Dispatchers.IO) {
val target = targetFile(session, attachment)
target.parentFile?.mkdirs()
val part = File(target.parentFile, target.name + ".part")
if (part.exists()) part.delete()
writer(part)
if (!isValid(part, attachment)) {
val expected = attachment.fileSizeBytes.takeIf { it > 0 }?.toString() ?: "non-empty"
val actual = if (part.exists()) part.length().toString() else "missing"
part.delete()
error("Attachment cache mismatch: expected $expected, got $actual")
}
if (target.exists()) target.delete()
if (!part.renameTo(target)) {
part.delete()
error("Cannot move uploaded file into attachment cache")
}
target
}
}
suspend fun clearAll() { suspend fun clearAll() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
root.deleteRecursively() root.deleteRecursively()
@@ -51,6 +81,7 @@ class AttachmentDiskCache(context: Context) {
attachment.downloadPath, attachment.downloadPath,
attachment.fileName, attachment.fileName,
attachment.contentType, attachment.contentType,
attachment.kind,
attachment.fileSizeBytes.toString() attachment.fileSizeBytes.toString()
).joinToString("|") ).joinToString("|")
).take(40) ).take(40)
@@ -145,6 +145,8 @@ class LocalMessageCache(context: Context) {
private fun isGenericMediaLabel(value: String?): Boolean { private fun isGenericMediaLabel(value: String?): Boolean {
return when (value?.trim()?.lowercase()) { return when (value?.trim()?.lowercase()) {
"\u043c\u0435\u0434\u0438\u0430",
"media",
"\u0444\u043e\u0442\u043e", "\u0444\u043e\u0442\u043e",
"photo", "photo",
"image", "image",
@@ -148,6 +148,19 @@ data class BeginMaxLoginRequest(val phoneNumber: String? = null)
@Serializable @Serializable
data class SubmitMaxCodeRequest(val code: String) data class SubmitMaxCodeRequest(val code: String)
@Serializable
data class MaxChannelSearchResultDto(
val externalId: String,
val title: String,
val avatarUrl: String? = null,
val chatUrl: String? = null,
val isSubscribed: Boolean = false,
val description: String? = null
)
@Serializable
data class SubscribeMaxChannelRequest(val link: String)
@Serializable @Serializable
data class RegisterPushDeviceRequest( data class RegisterPushDeviceRequest(
val firebaseToken: String, val firebaseToken: String,
@@ -31,10 +31,12 @@ import xyz.kusoft.qmax.core.model.EditMessageRequest
import xyz.kusoft.qmax.core.model.ForwardMessageRequest import xyz.kusoft.qmax.core.model.ForwardMessageRequest
import xyz.kusoft.qmax.core.model.MarkChatReadRequest import xyz.kusoft.qmax.core.model.MarkChatReadRequest
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.MessageDto import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.RegisterPushDeviceRequest import xyz.kusoft.qmax.core.model.RegisterPushDeviceRequest
import xyz.kusoft.qmax.core.model.SendMessageRequest import xyz.kusoft.qmax.core.model.SendMessageRequest
import xyz.kusoft.qmax.core.model.SetReactionRequest import xyz.kusoft.qmax.core.model.SetReactionRequest
import xyz.kusoft.qmax.core.model.SubscribeMaxChannelRequest
import xyz.kusoft.qmax.core.model.SubmitMaxCodeRequest import xyz.kusoft.qmax.core.model.SubmitMaxCodeRequest
import java.io.IOException import java.io.IOException
import java.io.File import java.io.File
@@ -79,8 +81,8 @@ class QMaxApi {
return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title)) return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title))
} }
suspend fun messages(sessionServerUrl: String, token: String, chatId: String): List<MessageDto> { suspend fun messages(sessionServerUrl: String, token: String, chatId: String, sync: Boolean = false): List<MessageDto> {
return get(sessionServerUrl, "/api/chats/$chatId/messages?sync=false", token) return get(sessionServerUrl, "/api/chats/$chatId/messages?sync=$sync", token)
} }
suspend fun markChatRead(sessionServerUrl: String, token: String, chatId: String) { suspend fun markChatRead(sessionServerUrl: String, token: String, chatId: String) {
@@ -91,10 +93,6 @@ class QMaxApi {
postNoContent(sessionServerUrl, "/api/chats/clear-history", token, ChatBulkActionRequest(chatIds)) postNoContent(sessionServerUrl, "/api/chats/clear-history", token, ChatBulkActionRequest(chatIds))
} }
suspend fun deleteChats(sessionServerUrl: String, token: String, chatIds: List<String>) {
postNoContent(sessionServerUrl, "/api/chats/delete", token, ChatBulkActionRequest(chatIds))
}
suspend fun searchMessages(sessionServerUrl: String, token: String, chatId: String, query: String): List<MessageDto> { suspend fun searchMessages(sessionServerUrl: String, token: String, chatId: String, query: String): List<MessageDto> {
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString()) val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token) return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token)
@@ -191,6 +189,15 @@ class QMaxApi {
return post(sessionServerUrl, "/api/max/login/code", token, SubmitMaxCodeRequest(code)) return post(sessionServerUrl, "/api/max/login/code", token, SubmitMaxCodeRequest(code))
} }
suspend fun searchMaxChannels(sessionServerUrl: String, token: String, query: String): List<MaxChannelSearchResultDto> {
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.name())
return get(sessionServerUrl, "/api/max/channels/search?q=$encodedQuery", token)
}
suspend fun subscribeMaxChannel(sessionServerUrl: String, token: String, link: String): ChatDto {
return post(sessionServerUrl, "/api/max/channels/subscribe", token, SubscribeMaxChannelRequest(link))
}
suspend fun registerPushDevice(sessionServerUrl: String, token: String, firebaseToken: String) { suspend fun registerPushDevice(sessionServerUrl: String, token: String, firebaseToken: String) {
postNoContent(sessionServerUrl, "/api/push/devices", token, RegisterPushDeviceRequest(firebaseToken)) postNoContent(sessionServerUrl, "/api/push/devices", token, RegisterPushDeviceRequest(firebaseToken))
} }
@@ -8,6 +8,7 @@ import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MessageDeletedDto import xyz.kusoft.qmax.core.model.MessageDeletedDto
import xyz.kusoft.qmax.core.model.MessageDto import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.QMaxSession import xyz.kusoft.qmax.core.model.QMaxSession
@@ -26,7 +27,8 @@ class QMaxRealtimeClient {
onChatListInvalidated: () -> Unit, onChatListInvalidated: () -> Unit,
onMessageCreated: (MessageDto) -> Unit, onMessageCreated: (MessageDto) -> Unit,
onMessageUpdated: (MessageDto) -> Unit, onMessageUpdated: (MessageDto) -> Unit,
onMessageDeleted: (MessageDeletedDto) -> Unit onMessageDeleted: (MessageDeletedDto) -> Unit,
onMaxStatusChanged: (MaxBridgeStatusDto) -> Unit
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
disconnect() disconnect()
@@ -36,6 +38,15 @@ class QMaxRealtimeClient {
.build() .build()
connection.on("ChatListInvalidated", onChatListInvalidated) connection.on("ChatListInvalidated", onChatListInvalidated)
connection.on(
"MaxStatusChanged",
{ payload: JsonElement ->
runCatching {
onMaxStatusChanged(json.decodeFromString<MaxBridgeStatusDto>(payload.toString()))
}
},
JsonElement::class.java
)
connection.on( connection.on(
"MessageCreated", "MessageCreated",
{ payload: JsonElement -> { payload: JsonElement ->
@@ -17,6 +17,7 @@ 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.MaxBridgeStatusDto import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
import xyz.kusoft.qmax.core.model.MessageDeletedDto import xyz.kusoft.qmax.core.model.MessageDeletedDto
import xyz.kusoft.qmax.core.model.MessageDto import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.QMaxSession import xyz.kusoft.qmax.core.model.QMaxSession
@@ -35,6 +36,9 @@ data class QMaxUiState(
val messages: List<MessageDto> = emptyList(), val messages: List<MessageDto> = emptyList(),
val chatSearchResults: List<MessageDto> = emptyList(), val chatSearchResults: List<MessageDto> = emptyList(),
val chatSearchLoading: Boolean = false, val chatSearchLoading: Boolean = false,
val channelSearchResults: List<MaxChannelSearchResultDto> = emptyList(),
val channelSearchLoading: Boolean = false,
val channelSearchError: String? = null,
val chatPresenceText: String? = null, val chatPresenceText: String? = null,
val replyTarget: MessageDto? = null, val replyTarget: MessageDto? = null,
val editTarget: MessageDto? = null, val editTarget: MessageDto? = null,
@@ -72,6 +76,8 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
private var chatRefreshJob: Job? = null private var chatRefreshJob: Job? = null
private var chatRefreshGeneration = 0L private var chatRefreshGeneration = 0L
private var chatSearchJob: Job? = null private var chatSearchJob: Job? = null
private var channelSearchJob: Job? = null
private var maxStatusPollJob: Job? = null
private var autoLoginJob: Job? = null private var autoLoginJob: Job? = null
private var pushRegisteredForToken: String? = null private var pushRegisteredForToken: String? = null
private var pushRegistrationJob: Job? = null private var pushRegistrationJob: Job? = null
@@ -81,6 +87,8 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
private val locallyReadChatFingerprints = mutableMapOf<String, String>() private val locallyReadChatFingerprints = mutableMapOf<String, String>()
private val imageCacheInFlight = mutableSetOf<String>() private val imageCacheInFlight = mutableSetOf<String>()
private val avatarCacheInFlight = mutableSetOf<String>() private val avatarCacheInFlight = mutableSetOf<String>()
private val messageHydrationInFlight = mutableSetOf<String>()
private val lastMessageHydrationAt = mutableMapOf<String, Long>()
init { init {
viewModelScope.launch { viewModelScope.launch {
@@ -88,6 +96,10 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
val previousSession = state.value.session val previousSession = state.value.session
val sessionChanged = previousSession?.serverUrl != session?.serverUrl || val sessionChanged = previousSession?.serverUrl != session?.serverUrl ||
previousSession?.userName != session?.userName previousSession?.userName != session?.userName
if (sessionChanged) {
messageHydrationInFlight.clear()
lastMessageHydrationAt.clear()
}
state.value = state.value.copy( state.value = state.value.copy(
session = session, session = session,
serverUrl = session?.serverUrl ?: state.value.serverUrl, serverUrl = session?.serverUrl ?: state.value.serverUrl,
@@ -101,11 +113,13 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
loadChats() loadChats()
connectRealtime(session) connectRealtime(session)
registerPushDevice(session) registerPushDevice(session)
loadMaxStatus() startMaxStatusPolling(session)
} else { } else {
realtimeConnectJob?.cancel() realtimeConnectJob?.cancel()
messagePollJob?.cancel() messagePollJob?.cancel()
presencePollJob?.cancel() presencePollJob?.cancel()
maxStatusPollJob?.cancel()
channelSearchJob?.cancel()
pushRegistrationJob?.cancel() pushRegistrationJob?.cancel()
pushRegistrationInFlightForToken = null pushRegistrationInFlightForToken = null
pushRegisteredForToken = null pushRegisteredForToken = null
@@ -154,6 +168,61 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
state.value = state.value.copy(searchQuery = value) state.value = state.value.copy(searchQuery = value)
} }
fun searchMaxChannels(query: String) {
channelSearchJob?.cancel()
val trimmed = query.trim()
if (trimmed.isBlank()) {
state.value = state.value.copy(
channelSearchResults = emptyList(),
channelSearchLoading = false,
channelSearchError = null
)
return
}
state.value = state.value.copy(channelSearchLoading = true, channelSearchError = null)
channelSearchJob = viewModelScope.launch {
delay(350)
val session = state.value.session ?: run {
state.value = state.value.copy(channelSearchLoading = false)
return@launch
}
runCatching {
repository.searchMaxChannels(session, trimmed)
}.onSuccess { results ->
state.value = state.value.copy(
channelSearchResults = results,
channelSearchLoading = false,
channelSearchError = null
)
}.onFailure { error ->
if (error is CancellationException) throw error
state.value = state.value.copy(
channelSearchResults = emptyList(),
channelSearchLoading = false,
channelSearchError = error.message ?: "Ошибка поиска каналов"
)
}
}
}
fun subscribeMaxChannel(result: MaxChannelSearchResultDto) = launchLoading(false) {
val session = requireSession()
val link = result.chatUrl?.takeIf { it.isNotBlank() } ?: result.externalId
val chat = repository.subscribeMaxChannel(session, link)
state.value = state.value.copy(
chats = normalizeChats(state.value.chats.filterNot { it.id == chat.id } + chat),
channelSearchResults = state.value.channelSearchResults.map {
if (it.externalId == result.externalId || it.chatUrl == result.chatUrl) {
it.copy(isSubscribed = true)
} else {
it
}
},
channelSearchError = null
)
loadChats()
}
fun beginChatSelection(chatId: String) { fun beginChatSelection(chatId: String) {
if (chatId.isBlank()) return if (chatId.isBlank()) return
state.value = state.value.copy(selectedChatIds = state.value.selectedChatIds + chatId) state.value = state.value.copy(selectedChatIds = state.value.selectedChatIds + chatId)
@@ -330,7 +399,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
} }
realtime.joinChat(chat.id) realtime.joinChat(chat.id)
} }
loadMessages(showLoading = false) loadMessages(showLoading = false, forceHydrate = true)
presencePollJob?.cancel() presencePollJob?.cancel()
presencePollJob = viewModelScope.launch { presencePollJob = viewModelScope.launch {
while (state.value.selectedChat?.id == chat.id) { while (state.value.selectedChat?.id == chat.id) {
@@ -427,36 +496,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
refreshChats(showLoading = false) refreshChats(showLoading = false)
} }
fun deleteSelectedChats() = launchLoading(false) { fun loadMessages(showLoading: Boolean = true, forceHydrate: Boolean = false) = launchLoading(showLoading) {
val session = requireSession()
val ids = state.value.selectedChatIds
if (ids.isEmpty()) return@launchLoading
val selectedChatId = state.value.selectedChat?.id
val selectedChatDeleted = selectedChatId != null && selectedChatId in ids
val updatedChats = normalizeChats(state.value.chats.filterNot { it.id in ids })
state.value = state.value.copy(
selectedChat = if (selectedChatDeleted) null else state.value.selectedChat,
chats = updatedChats,
messages = if (selectedChatDeleted) emptyList() else state.value.messages,
chatPresenceText = if (selectedChatDeleted) null else state.value.chatPresenceText,
replyTarget = if (selectedChatDeleted) null else state.value.replyTarget,
editTarget = if (selectedChatDeleted) null else state.value.editTarget,
forwardTarget = if (selectedChatDeleted) null else state.value.forwardTarget,
composerText = if (selectedChatDeleted) "" else state.value.composerText,
selectedChatIds = emptySet()
)
if (selectedChatDeleted) {
messagePollJob?.cancel()
presencePollJob?.cancel()
realtime.joinChat(null)
}
persistCurrentChats()
repository.deleteChats(session, ids)
refreshChats(showLoading = false)
}
fun loadMessages(showLoading: Boolean = true) = launchLoading(showLoading) {
if (!showLoading && state.value.sendingMessage) { if (!showLoading && state.value.sendingMessage) {
return@launchLoading return@launchLoading
} }
@@ -485,6 +525,39 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
messages = fresh messages = fresh
) )
persistCurrentChats() persistCurrentChats()
hydrateMessages(chat.id, force = forceHydrate)
}
}
private fun hydrateMessages(chatId: String, force: Boolean = false) {
val session = state.value.session ?: return
val now = System.currentTimeMillis()
val lastHydratedAt = lastMessageHydrationAt[chatId] ?: 0L
if (!force && now - lastHydratedAt < MessageHydrationIntervalMs) {
return
}
if (!messageHydrationInFlight.add(chatId)) {
return
}
lastMessageHydrationAt[chatId] = now
viewModelScope.launch {
try {
val hydrated = withTimeout(MessageSyncTimeoutMs) {
repository.messages(session, chatId, sync = true)
}
if (state.value.selectedChat?.id == chatId) {
state.value = state.value.copy(messages = hydrated)
}
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
if (BuildConfig.DEBUG) {
Log.d(LogTag, "Message hydration failed", error)
}
} finally {
messageHydrationInFlight.remove(chatId)
}
} }
} }
@@ -501,9 +574,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
val replacement = findReplacementChat(staleChat, orderedFresh) val replacement = findReplacementChat(staleChat, orderedFresh)
if (replacement == null) { if (replacement == null) {
state.value = state.value.copy( state.value = state.value.copy(
selectedChat = null,
chats = orderedFresh, chats = orderedFresh,
messages = emptyList(),
error = null error = null
) )
repository.cacheChats(session, orderedFresh) repository.cacheChats(session, orderedFresh)
@@ -679,19 +750,20 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
fun loadMaxStatus() = launchLoading(false) { fun loadMaxStatus() = launchLoading(false) {
val session = requireSession() val session = requireSession()
state.value = state.value.copy(maxStatus = repository.maxStatus(session)) refreshMaxStatus(session)
} }
fun startMaxLogin() = launchLoading { fun startMaxLogin() = launchLoading {
val session = requireSession() val session = requireSession()
state.value = state.value.copy(maxStatus = repository.startMaxLogin(session, null)) applyMaxStatus(repository.startMaxLogin(session, null))
} }
fun submitMaxCode() = launchLoading { fun submitMaxCode() = launchLoading {
val session = requireSession() val session = requireSession()
val code = state.value.maxCode.trim() val code = state.value.maxCode.trim()
if (code.isBlank()) return@launchLoading if (code.isBlank()) return@launchLoading
state.value = state.value.copy(maxStatus = repository.submitMaxCode(session, code), maxCode = "") applyMaxStatus(repository.submitMaxCode(session, code))
state.value = state.value.copy(maxCode = "")
} }
fun checkArgusUpdate() = launchLoading { fun checkArgusUpdate() = launchLoading {
@@ -1036,6 +1108,28 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
} }
} }
private fun startMaxStatusPolling(session: QMaxSession) {
maxStatusPollJob?.cancel()
maxStatusPollJob = viewModelScope.launch {
while (true) {
runCatching {
refreshMaxStatus(session)
}.onFailure {
Log.w(LogTag, "MAX status refresh failed", it)
}
delay(MaxStatusPollIntervalMs)
}
}
}
private suspend fun refreshMaxStatus(session: QMaxSession) {
applyMaxStatus(repository.maxStatus(session))
}
private fun applyMaxStatus(status: MaxBridgeStatusDto) {
state.value = state.value.copy(maxStatus = status)
}
private fun connectRealtime(session: QMaxSession) { private fun connectRealtime(session: QMaxSession) {
realtimeConnectJob?.cancel() realtimeConnectJob?.cancel()
realtimeConnectJob = viewModelScope.launch { realtimeConnectJob = viewModelScope.launch {
@@ -1045,7 +1139,8 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
onChatListInvalidated = ::scheduleChatListRefresh, onChatListInvalidated = ::scheduleChatListRefresh,
onMessageCreated = ::handleRealtimeMessage, onMessageCreated = ::handleRealtimeMessage,
onMessageUpdated = ::handleRealtimeMessageUpdated, onMessageUpdated = ::handleRealtimeMessageUpdated,
onMessageDeleted = ::handleRealtimeMessageDeleted onMessageDeleted = ::handleRealtimeMessageDeleted,
onMaxStatusChanged = ::applyMaxStatus
) )
realtime.joinChat(state.value.selectedChat?.id) realtime.joinChat(state.value.selectedChat?.id)
} }
@@ -1236,6 +1331,8 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
const val LogTag = "QMAX" const val LogTag = "QMAX"
const val ChatListTimeoutMs = 45_000L const val ChatListTimeoutMs = 45_000L
const val MessageSyncTimeoutMs = 120_000L const val MessageSyncTimeoutMs = 120_000L
const val MessageHydrationIntervalMs = 120_000L
const val MaxStatusPollIntervalMs = 60_000L
const val AutoLoginDelayMs = 1_000L const val AutoLoginDelayMs = 1_000L
const val PushRegistrationAttempts = 6 const val PushRegistrationAttempts = 6
const val InitialPushRegistrationRetryMs = 15_000L const val InitialPushRegistrationRetryMs = 15_000L
+1 -4
View File
@@ -8,11 +8,8 @@ QMAX_JWT_SECRET=change-me-to-a-long-random-secret-at-least-32-characters
# Enter this once on the Android login screen to pair the device with your private bridge. # Enter this once on the Android login screen to pair the device with your private bridge.
QMAX_PAIRING_CODE=change-me-pairing-code QMAX_PAIRING_CODE=change-me-pairing-code
# Use international format if MAX accepts it in your flow, otherwise the local phone format you use in MAX Web. # Use the phone number linked to the MAX account used by PyMax.
QMAX_MAX_PHONE_NUMBER=79000000000 QMAX_MAX_PHONE_NUMBER=79000000000
# Set false temporarily if you attach a visible browser/VNC workflow for CAPTCHA/debug.
QMAX_MAX_HEADLESS=true
QMAX_CORS_ALLOWED_ORIGINS= QMAX_CORS_ALLOWED_ORIGINS=
# Optional Firebase Cloud Messaging. Mount the service account JSON into the API container # Optional Firebase Cloud Messaging. Mount the service account JSON into the API container
+13 -11
View File
@@ -15,7 +15,7 @@ services:
QMax__PairingCode: ${QMAX_PAIRING_CODE} QMax__PairingCode: ${QMAX_PAIRING_CODE}
QMax__MaxPhoneNumber: ${QMAX_MAX_PHONE_NUMBER} QMax__MaxPhoneNumber: ${QMAX_MAX_PHONE_NUMBER}
QMax__MaxMode: Worker QMax__MaxMode: Worker
QMax__MaxWorkerBaseUrl: http://qmax-max-worker:3001 QMax__MaxWorkerBaseUrl: http://qmax-pymax-worker:3002
QMax__CorsAllowedOrigins: ${QMAX_CORS_ALLOWED_ORIGINS:-} QMax__CorsAllowedOrigins: ${QMAX_CORS_ALLOWED_ORIGINS:-}
QMax__PushEnabled: ${QMAX_PUSH_ENABLED:-true} QMax__PushEnabled: ${QMAX_PUSH_ENABLED:-true}
QMax__PushShowPreview: ${QMAX_PUSH_SHOW_PREVIEW:-true} QMax__PushShowPreview: ${QMAX_PUSH_SHOW_PREVIEW:-true}
@@ -28,30 +28,32 @@ services:
ports: ports:
- "127.0.0.1:18080:8080" - "127.0.0.1:18080:8080"
depends_on: depends_on:
- qmax-max-worker - qmax-pymax-worker
networks: networks:
- qmax - qmax
qmax-max-worker: qmax-pymax-worker:
build: build:
context: ../worker context: ../pymax-worker
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: qmax-max-worker container_name: qmax-pymax-worker
restart: unless-stopped restart: unless-stopped
environment: environment:
PORT: 3001 PORT: 3002
MAX_BASE_URL: https://web.max.ru/ PYMAX_PHONE_NUMBER: ${QMAX_MAX_PHONE_NUMBER}
MAX_USER_DATA_DIR: /data/max-profile PYMAX_SESSION_DIR: /data/pymax-session
MAX_HEADLESS: ${QMAX_MAX_HEADLESS:-true} PYMAX_SEND_ROOTS: /qmax-data:/tmp
PYMAX_CHAT_FETCH_LIMIT: ${QMAX_PYMAX_CHAT_FETCH_LIMIT:-350}
PYMAX_HISTORY_LIMIT: ${QMAX_PYMAX_HISTORY_LIMIT:-80}
volumes: volumes:
- qmax-max-profile:/data - qmax-pymax-session:/data
- qmax-data:/qmax-data - qmax-data:/qmax-data
networks: networks:
- qmax - qmax
volumes: volumes:
qmax-data: qmax-data:
qmax-max-profile: qmax-pymax-session:
networks: networks:
qmax: qmax:
+7 -7
View File
@@ -6,31 +6,31 @@ flowchart LR
A -->|SignalR planned/available| B A -->|SignalR planned/available| B
B --> C["SQLite cache<br/>chats/messages/sessions"] B --> C["SQLite cache<br/>chats/messages/sessions"]
B --> D["Local storage<br/>attachments/releases"] B --> D["Local storage<br/>attachments/releases"]
B -->|HTTP internal| E["MAX worker<br/>Node + Playwright"] B -->|HTTP internal| E["MAX worker<br/>Python + PyMax"]
E -->|persistent browser profile| F["web.max.ru"] E -->|persistent PyMax session| F["MAX mobile API"]
B --> G["Caddy TLS<br/>qmax.kusoft.xyz"] B --> G["Caddy TLS<br/>qmax.kusoft.xyz"]
``` ```
The server is the only public backend surface. The MAX worker stays inside the Docker network and is controlled through the API/admin page. The server is the only public backend surface. The PyMax worker stays inside the Docker network and is controlled through the API/admin page.
## Security Rules ## Security Rules
- Android pairs to the private server with `QMAX_PAIRING_CODE`. - Android pairs to the private server with `QMAX_PAIRING_CODE`.
- API access uses JWT access tokens and refresh tokens. - API access uses JWT access tokens and refresh tokens.
- MAX credentials and sessions live only on the Pi in Docker volumes. - MAX session files live only on the Pi in Docker volumes.
- `.env`, service-account files, keystores and runtime data are ignored by git. - `.env`, service-account files, keystores and runtime data are ignored by git.
- Attachments are written as `.part` first and moved into place only after full upload. - Attachments are written as `.part` first and moved into place only after full upload.
- Android image attachments are downloaded to a local `.part` cache and exposed to the UI only after size validation. - Android image attachments are downloaded to a local `.part` cache and exposed to the UI only after size validation.
## Current Verified Status ## Current Verified Status
- MAX Web login is authorized on the Raspberry Pi worker. - PyMax login is authorized on the Raspberry Pi worker.
- Chat list, message history, text sending and attachment sending are mapped through `worker/src/server.js`. - Chat list, message history, text sending and attachment sending are mapped through `pymax-worker/src/server.py`.
- Image, video, file and voice attachments are projected through the API and rendered by the Android client. - Image, video, file and voice attachments are projected through the API and rendered by the Android client.
- Firebase initialization and Android push token registration are enabled for `xyz.kusoft.qmax`. - Firebase initialization and Android push token registration are enabled for `xyz.kusoft.qmax`.
## Remaining Production Checks ## Remaining Production Checks
- Keep selector drift monitoring for future MAX Web UI changes. - Keep PyMax session-expiration monitoring active.
- Run an unlocked-device visual pass on the connected phone for every major UI change. - Run an unlocked-device visual pass on the connected phone for every major UI change.
- Replace debug fallback signing with a real release key in `android/key.properties`. - Replace debug fallback signing with a real release key in `android/key.properties`.
+23
View File
@@ -0,0 +1,23 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir \
"aiofiles>=25.1.0" \
"aiohttp>=3.9.0" \
"aiosqlite>=0.22.0" \
"msgpack>=1.1.0" \
"pydantic>=2.10.0" \
"python-socks[asyncio]>=2.8.0" \
"qrcode>=8.2" \
"websockets>=16.0" \
"zstandard>=0.25.0" \
&& pip install --no-cache-dir --no-deps "maxapi-python==2.3.1"
COPY src ./src
ENV PORT=3002
ENV PYMAX_SESSION_DIR=/data/pymax-session
EXPOSE 3002
CMD ["python", "-m", "src.server"]
+9
View File
@@ -0,0 +1,9 @@
aiofiles>=25.1.0
aiohttp>=3.9.0
aiosqlite>=0.22.0
msgpack>=1.1.0
pydantic>=2.10.0
python-socks[asyncio]>=2.8.0
qrcode>=8.2
websockets>=16.0
zstandard>=0.25.0
+1
View File
@@ -0,0 +1 @@
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -23,14 +23,15 @@ if (Test-Path $staging) {
} }
New-Item -ItemType Directory -Force -Path $staging | Out-Null New-Item -ItemType Directory -Force -Path $staging | Out-Null
foreach ($item in @("server", "worker", "deploy", "Dockerfile.server", "QMax.slnx")) { foreach ($item in @("server", "pymax-worker", "deploy", "Dockerfile.server", "QMax.slnx")) {
Copy-Item -LiteralPath (Join-Path $root $item) -Destination $staging -Recurse -Force Copy-Item -LiteralPath (Join-Path $root $item) -Destination $staging -Recurse -Force
} }
foreach ($path in @( foreach ($path in @(
"server/QMax.Api/bin", "server/QMax.Api/bin",
"server/QMax.Api/obj", "server/QMax.Api/obj",
"worker/node_modules" "deploy/releases",
"pymax-worker/src/__pycache__"
)) { )) {
$target = Join-Path $staging $path $target = Join-Path $staging $path
if (Test-Path $target) { if (Test-Path $target) {
@@ -40,7 +41,7 @@ foreach ($path in @(
Push-Location $staging Push-Location $staging
try { try {
tar -czf $archive server worker deploy Dockerfile.server QMax.slnx tar -czf $archive server pymax-worker deploy Dockerfile.server QMax.slnx
} }
finally { finally {
Pop-Location Pop-Location
+1 -1
View File
@@ -11,7 +11,7 @@ public sealed class QMaxOptions
public string PairingCode { get; set; } = ""; public string PairingCode { get; set; } = "";
public string MaxPhoneNumber { get; set; } = ""; public string MaxPhoneNumber { get; set; } = "";
public string MaxMode { get; set; } = "Worker"; public string MaxMode { get; set; } = "Worker";
public string MaxWorkerBaseUrl { get; set; } = "http://qmax-max-worker:3001"; public string MaxWorkerBaseUrl { get; set; } = "http://qmax-pymax-worker:3002";
public string MaxUserDataPath { get; set; } = "data/max-profile"; public string MaxUserDataPath { get; set; } = "data/max-profile";
public bool MaxHeadless { get; set; } = true; public bool MaxHeadless { get; set; } = true;
public int MaxPollIntervalSeconds { get; set; } = 6; public int MaxPollIntervalSeconds { get; set; } = 6;
+10
View File
@@ -18,3 +18,13 @@ public sealed record MaxBrowserSnapshotDto(
string BodyText, string BodyText,
string ScreenshotPngBase64, string ScreenshotPngBase64,
DateTimeOffset CapturedAt); DateTimeOffset CapturedAt);
public sealed record MaxChannelSearchResultDto(
string ExternalId,
string Title,
string? AvatarUrl,
string? ChatUrl,
bool IsSubscribed,
string? Description);
public sealed record SubscribeMaxChannelRequest(string Link);
+2 -24
View File
@@ -367,33 +367,11 @@ public sealed class AdminController(
} }
[HttpDelete("api/chats/{id:guid}")] [HttpDelete("api/chats/{id:guid}")]
public async Task<IActionResult> DeleteChat(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken) public IActionResult DeleteChat(Guid id, [FromQuery] string? pairingCode)
{ {
if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
var chat = await db.Chats return BadRequest("Chat deletion is disabled because MAX does not remove chats from the web client.");
.Include(x => x.Messages)
.ThenInclude(x => x.Attachments)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (chat is null)
{
return NotFound();
}
var files = chat.Messages
.SelectMany(x => x.Attachments)
.Select(x => storage.GetPath(x.StorageFileName))
.Where(System.IO.File.Exists)
.ToArray();
db.Chats.Remove(chat);
await db.SaveChangesAsync(cancellationToken);
foreach (var file in files)
{
System.IO.File.Delete(file);
}
return NoContent();
} }
private async Task<AdminStatusDto> BuildStatusAsync(CancellationToken cancellationToken) private async Task<AdminStatusDto> BuildStatusAsync(CancellationToken cancellationToken)
+16 -25
View File
@@ -106,6 +106,7 @@ public sealed class ChatsController(
} }
var messages = (await query.ToArrayAsync(cancellationToken)) var messages = (await query.ToArrayAsync(cancellationToken))
.Where(message => !IsGenericPreviewPlaceholder(message))
.OrderByDescending(x => x.SentAt) .OrderByDescending(x => x.SentAt)
.Take(Math.Clamp(take, 1, 200)) .Take(Math.Clamp(take, 1, 200))
.ToArray(); .ToArray();
@@ -121,6 +122,11 @@ public sealed class ChatsController(
return; return;
} }
if (MessageTextSanitizer.IsGenericMediaLabel(preview))
{
return;
}
var previewAt = chat.LastMessageAt.Value; var previewAt = chat.LastMessageAt.Value;
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt) if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt)
{ {
@@ -202,6 +208,14 @@ public sealed class ChatsController(
return hash.ToString("x8"); return hash.ToString("x8");
} }
private static bool IsGenericPreviewPlaceholder(Message message)
{
return message.Direction == MessageDirection.Incoming &&
message.Attachments.Count == 0 &&
message.ExternalId?.StartsWith("preview:", StringComparison.Ordinal) == true &&
MessageTextSanitizer.IsGenericMediaLabel(message.Text);
}
[HttpPost("{chatId:guid}/read")] [HttpPost("{chatId:guid}/read")]
public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken) public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken)
{ {
@@ -260,7 +274,7 @@ public sealed class ChatsController(
} }
[HttpPost("delete")] [HttpPost("delete")]
public async Task<IActionResult> DeleteChats(ChatBulkActionRequest request, CancellationToken cancellationToken) public IActionResult DeleteChats(ChatBulkActionRequest request)
{ {
var chatIds = request.ChatIds.Distinct().ToArray(); var chatIds = request.ChatIds.Distinct().ToArray();
if (chatIds.Length == 0) if (chatIds.Length == 0)
@@ -268,30 +282,7 @@ public sealed class ChatsController(
return BadRequest("chatIds are required."); return BadRequest("chatIds are required.");
} }
var chats = await LoadChatsWithAttachmentsAsync(chatIds, cancellationToken); return BadRequest("Chat deletion is disabled because MAX does not remove chats from the web client.");
if (chats.Count != chatIds.Length)
{
return NotFound();
}
var now = DateTimeOffset.UtcNow;
var files = AttachmentFiles(chats).ToArray();
foreach (var chat in chats)
{
db.Messages.RemoveRange(chat.Messages);
chat.DeletedAt ??= now;
chat.HistoryClearedAt = now;
chat.LastMessageAt = null;
chat.LastMessagePreview = null;
chat.UnreadCount = 0;
chat.UpdatedAt = now;
QueuePendingMaxAction(chat, ChatPendingMaxAction.DeleteChat, now);
}
await db.SaveChangesAsync(cancellationToken);
DeleteFiles(files);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
return NoContent();
} }
[HttpGet("{chatId:guid}/search")] [HttpGet("{chatId:guid}/search")]
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -6,6 +7,7 @@ using QMax.Api.Configuration;
using QMax.Api.Contracts; using QMax.Api.Contracts;
using QMax.Api.Data; using QMax.Api.Data;
using QMax.Api.Data.Entities; using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max; using QMax.Api.Infrastructure.Max;
using QMax.Api.Services; using QMax.Api.Services;
@@ -18,6 +20,8 @@ public sealed class MaxController(
IMaxBridgeClient maxBridge, IMaxBridgeClient maxBridge,
MaxBridgeSyncService syncService, MaxBridgeSyncService syncService,
QMaxDbContext db, QMaxDbContext db,
IHubContext<QMaxHub> hubContext,
ChatProjectionService projection,
IOptions<QMaxOptions> options) : ControllerBase IOptions<QMaxOptions> options) : ControllerBase
{ {
private readonly QMaxOptions _options = options.Value; private readonly QMaxOptions _options = options.Value;
@@ -66,6 +70,51 @@ public sealed class MaxController(
return new { changed }; return new { changed };
} }
[HttpGet("channels/search")]
public async Task<ActionResult<IReadOnlyList<MaxChannelSearchResultDto>>> SearchChannels(
string q,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(q))
{
return Array.Empty<MaxChannelSearchResultDto>();
}
var results = await maxBridge.SearchChannelsAsync(q.Trim(), cancellationToken);
return results.Select(ToDto).ToArray();
}
[HttpPost("channels/subscribe")]
public async Task<ActionResult<ChatDto>> SubscribeChannel(
SubscribeMaxChannelRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Link))
{
return BadRequest("Channel link is required.");
}
var update = await maxBridge.JoinChannelAsync(request.Link.Trim(), cancellationToken);
if (update is null)
{
return BadRequest("MAX did not return a channel for this link.");
}
await syncService.ApplyJoinedChannelAsync(update, cancellationToken);
var chat = await db.Chats.FirstOrDefaultAsync(
x => x.DeletedAt == null &&
(x.ExternalId == update.ExternalId ||
(!string.IsNullOrWhiteSpace(update.ChatUrl) && x.WebUrl == update.ChatUrl)),
cancellationToken);
if (chat is null)
{
return BadRequest("Channel was joined but was not saved in QMAX.");
}
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
return projection.ToDto(chat);
}
private async Task SaveStateAsync(MaxBridgeStatus status, CancellationToken cancellationToken) private async Task SaveStateAsync(MaxBridgeStatus status, CancellationToken cancellationToken)
{ {
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken); var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
@@ -82,10 +131,16 @@ public sealed class MaxController(
state.LastError = status.LastError; state.LastError = status.LastError;
state.UpdatedAt = DateTimeOffset.UtcNow; state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
} }
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status) private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status)
{ {
return new MaxBridgeStatusDto(status.Mode, status.IsAuthorized, status.LoginStage, status.Status, status.Url, status.Title, status.LastError, status.UpdatedAt); return new MaxBridgeStatusDto(status.Mode, status.IsAuthorized, status.LoginStage, status.Status, status.Url, status.Title, status.LastError, status.UpdatedAt);
} }
private static MaxChannelSearchResultDto ToDto(MaxChannelSearchResult channel)
{
return new MaxChannelSearchResultDto(channel.ExternalId, channel.Title, channel.AvatarUrl, channel.ChatUrl, channel.IsSubscribed, channel.Description);
}
} }
@@ -19,4 +19,13 @@ public interface IMaxBridgeClient
Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken); Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken);
Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken); Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken);
Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken); Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken);
Task<IReadOnlyList<MaxChannelSearchResult>> SearchChannelsAsync(string query, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<MaxChannelSearchResult>>(Array.Empty<MaxChannelSearchResult>());
}
Task<MaxChatUpdate?> JoinChannelAsync(string link, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatUpdate?>(null);
}
} }
@@ -27,7 +27,8 @@ public sealed record MaxChatUpdate(
bool? LastMessageIsOutgoing = null, bool? LastMessageIsOutgoing = null,
DateTimeOffset? LastMessageAt = null, DateTimeOffset? LastMessageAt = null,
string? LastMessageTimeText = null, string? LastMessageTimeText = null,
string? ChatUrl = null); string? ChatUrl = null,
string? Kind = null);
public sealed record MaxMessageUpdate( public sealed record MaxMessageUpdate(
string ExternalId, string ExternalId,
@@ -64,3 +65,11 @@ public sealed record MaxChatPresence(
bool IsTyping, bool IsTyping,
string? StatusText, string? StatusText,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
public sealed record MaxChannelSearchResult(
string ExternalId,
string Title,
string? AvatarUrl,
string? ChatUrl,
bool IsSubscribed,
string? Description = null);
@@ -15,7 +15,7 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
"max", "max",
"MAX", "MAX",
false, false,
"Mock mode is active. Switch QMax:MaxMode to Playwright for real web.max.ru.", "Mock mode is active. Switch QMax:MaxMode to Worker and use qmax-pymax-worker for real MAX traffic.",
DateTimeOffset.UtcNow) DateTimeOffset.UtcNow)
]) ])
]; ];
@@ -68,7 +68,9 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult(new MaxActionResult(true, null)); return Task.FromResult(new MaxActionResult(
false,
"Chat deletion is disabled because MAX does not remove chats from the web client."));
} }
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
@@ -106,8 +108,32 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
return Task.FromResult<MaxMediaDownload?>(null); return Task.FromResult<MaxMediaDownload?>(null);
} }
public Task<IReadOnlyList<MaxChannelSearchResult>> SearchChannelsAsync(string query, CancellationToken cancellationToken)
{
IReadOnlyList<MaxChannelSearchResult> result = string.IsNullOrWhiteSpace(query)
? []
: [new MaxChannelSearchResult("mock-channel", "Mock channel", null, "mock-channel", false, "Mock MAX channel")];
return Task.FromResult(result);
}
public Task<MaxChatUpdate?> JoinChannelAsync(string link, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatUpdate?>(new MaxChatUpdate(
"mock-channel",
"Mock channel",
null,
DateTimeOffset.UtcNow,
[],
"Mock channel subscribed",
false,
DateTimeOffset.UtcNow,
null,
MockChatUrl("mock-channel"),
"Channel"));
}
private static string MockChatUrl(string externalChatId) private static string MockChatUrl(string externalChatId)
{ {
return $"https://web.max.ru/mock/{Uri.EscapeDataString(externalChatId)}"; return $"pymax://mock/{Uri.EscapeDataString(externalChatId)}";
} }
} }
@@ -40,7 +40,7 @@ public sealed class WorkerMaxBridgeClient(
public async Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken) public async Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken)
{ {
return await SendAsync<IReadOnlyList<MaxChatUpdate>>(HttpMethod.Get, "/updates", null, cancellationToken) return await SendAsync<IReadOnlyList<MaxChatUpdate>>(HttpMethod.Get, "/updates", null, cancellationToken)
?? Array.Empty<MaxChatUpdate>(); ?? throw new InvalidOperationException("MAX worker returned an empty updates response.");
} }
public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
@@ -69,14 +69,11 @@ public sealed class WorkerMaxBridgeClient(
?? new MaxActionResult(false, "Worker returned an empty clear history result."); ?? new MaxActionResult(false, "Worker returned an empty clear history result.");
} }
public async Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return await SendAsync<MaxActionResult>( return Task.FromResult(new MaxActionResult(
HttpMethod.Post, false,
"/chat/delete", "Chat deletion is disabled because MAX does not remove chats from the web client."));
new { externalChatId, chatUrl },
cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty delete chat result.");
} }
public async Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) public async Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
@@ -163,6 +160,25 @@ public sealed class WorkerMaxBridgeClient(
} }
} }
public async Task<IReadOnlyList<MaxChannelSearchResult>> SearchChannelsAsync(string query, CancellationToken cancellationToken)
{
return await SendAsync<IReadOnlyList<MaxChannelSearchResult>>(
HttpMethod.Post,
"/channels/search",
new { query },
cancellationToken)
?? Array.Empty<MaxChannelSearchResult>();
}
public async Task<MaxChatUpdate?> JoinChannelAsync(string link, CancellationToken cancellationToken)
{
return await SendAsync<MaxChatUpdate>(
HttpMethod.Post,
"/channels/join",
new { link },
cancellationToken);
}
private async Task<T?> SendAsync<T>(HttpMethod method, string path, object? body, CancellationToken cancellationToken) private async Task<T?> SendAsync<T>(HttpMethod method, string path, object? body, CancellationToken cancellationToken)
{ {
try try
+318 -26
View File
@@ -1,5 +1,8 @@
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using QMax.Api.Contracts;
using QMax.Api.Data; using QMax.Api.Data;
using QMax.Api.Data.Entities; using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs; using QMax.Api.Infrastructure.Hubs;
@@ -29,10 +32,41 @@ public sealed class MaxBridgeSyncService(
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "MAX sync failed."); logger.LogWarning(ex, "MAX sync failed.");
await PublishCurrentMaxStatusAsync(cancellationToken);
return 0; return 0;
} }
} }
private async Task PublishCurrentMaxStatusAsync(CancellationToken cancellationToken)
{
try
{
var status = await maxBridgeClient.GetStatusAsync(cancellationToken);
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var options = scope.ServiceProvider.GetRequiredService<IOptions<QMaxOptions>>().Value;
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
if (state is null)
{
state = new MaxAccountState { Id = 1 };
db.MaxAccountStates.Add(state);
}
state.PhoneNumber = options.MaxPhoneNumber;
state.Status = status.Status;
state.IsAuthorized = status.IsAuthorized;
state.LastUrl = status.Url;
state.LastError = status.LastError;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
}
catch (Exception statusError)
{
logger.LogDebug(statusError, "Unable to publish current MAX status after sync failure.");
}
}
public async Task<int> SyncChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) public async Task<int> SyncChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
if (string.IsNullOrWhiteSpace(externalChatId)) if (string.IsNullOrWhiteSpace(externalChatId))
@@ -45,7 +79,12 @@ public sealed class MaxBridgeSyncService(
var update = await maxBridgeClient.FetchChatHistoryAsync(externalChatId, chatUrl, cancellationToken); var update = await maxBridgeClient.FetchChatHistoryAsync(externalChatId, chatUrl, cancellationToken);
return update is null return update is null
? 0 ? 0
: await ApplyUpdatesAsync([update], incrementUnread: false, sendPushNotifications: false, cancellationToken); : await ApplyUpdatesAsync(
[update],
incrementUnread: false,
sendPushNotifications: false,
cancellationToken,
allowGenericMediaHistoryFetch: false);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -54,11 +93,22 @@ public sealed class MaxBridgeSyncService(
} }
} }
public async Task<int> ApplyJoinedChannelAsync(MaxChatUpdate update, CancellationToken cancellationToken)
{
return await ApplyUpdatesAsync(
[update],
incrementUnread: false,
sendPushNotifications: false,
cancellationToken,
allowGenericMediaHistoryFetch: false);
}
private async Task<int> ApplyUpdatesAsync( private async Task<int> ApplyUpdatesAsync(
IReadOnlyList<MaxChatUpdate> updates, IReadOnlyList<MaxChatUpdate> updates,
bool incrementUnread, bool incrementUnread,
bool sendPushNotifications, bool sendPushNotifications,
CancellationToken cancellationToken) CancellationToken cancellationToken,
bool allowGenericMediaHistoryFetch = true)
{ {
if (updates.Count == 0) if (updates.Count == 0)
{ {
@@ -73,11 +123,14 @@ public sealed class MaxBridgeSyncService(
var changed = 0; var changed = 0;
var createdMessages = new List<(Chat Chat, Message Message)>(); var createdMessages = new List<(Chat Chat, Message Message)>();
var updatedMessages = new List<(Chat Chat, Message Message)>(); var updatedMessages = new List<(Chat Chat, Message Message)>();
var previewNotificationMessages = new List<(Chat Chat, Message Message)>();
var genericMediaHistoryRequests = new List<(string ExternalChatId, string? ChatUrl, DateTimeOffset PreviewAt, bool IncrementUnread)>();
var trackedChatsByExternalId = new Dictionary<string, Chat>(StringComparer.Ordinal); var trackedChatsByExternalId = new Dictionary<string, Chat>(StringComparer.Ordinal);
foreach (var update in updates) foreach (var update in updates)
{ {
var nextWebUrl = CleanWebUrl(update.ChatUrl); var nextWebUrl = CleanWebUrl(update.ChatUrl);
var nextKind = ResolveChatKind(update.Kind);
if (!trackedChatsByExternalId.TryGetValue(update.ExternalId, out var chat)) if (!trackedChatsByExternalId.TryGetValue(update.ExternalId, out var chat))
{ {
chat = await db.Chats chat = await db.Chats
@@ -87,6 +140,10 @@ public sealed class MaxBridgeSyncService(
chat = await db.Chats chat = await db.Chats
.FirstOrDefaultAsync(x => x.WebUrl == nextWebUrl, cancellationToken); .FirstOrDefaultAsync(x => x.WebUrl == nextWebUrl, cancellationToken);
} }
if (chat is null)
{
chat = await FindDeletedChatTombstoneAsync(db, update, nextWebUrl, cancellationToken);
}
} }
if (chat?.DeletedAt is not null) if (chat?.DeletedAt is not null)
@@ -104,7 +161,7 @@ public sealed class MaxBridgeSyncService(
Title = update.Title, Title = update.Title,
AvatarUrl = update.AvatarUrl, AvatarUrl = update.AvatarUrl,
WebUrl = nextWebUrl, WebUrl = nextWebUrl,
Kind = ChatKind.MaxDialog Kind = nextKind ?? ChatKind.MaxDialog
}; };
db.Chats.Add(chat); db.Chats.Add(chat);
changed++; changed++;
@@ -141,6 +198,12 @@ public sealed class MaxBridgeSyncService(
metadataChanged = true; metadataChanged = true;
} }
if (nextKind is { } resolvedKind && chat.Kind != resolvedKind)
{
chat.Kind = resolvedKind;
metadataChanged = true;
}
if (metadataChanged) if (metadataChanged)
{ {
chat.UpdatedAt = DateTimeOffset.UtcNow; chat.UpdatedAt = DateTimeOffset.UtcNow;
@@ -181,7 +244,9 @@ public sealed class MaxBridgeSyncService(
chat, chat,
chatWasCreated, chatWasCreated,
incrementUnread, incrementUnread,
lastKnownMessage); lastKnownMessage,
createPreviewMessages: chat.Kind != ChatKind.Channel,
incrementGenericMediaUnread: chat.Kind == ChatKind.Channel);
if (previewResult.Message is not null) if (previewResult.Message is not null)
{ {
if (!await HasPreviewPlaceholderAsync(db, chat.Id, previewResult.Message, cancellationToken)) if (!await HasPreviewPlaceholderAsync(db, chat.Id, previewResult.Message, cancellationToken))
@@ -190,6 +255,27 @@ public sealed class MaxBridgeSyncService(
createdMessages.Add((chat, previewResult.Message)); createdMessages.Add((chat, previewResult.Message));
} }
} }
if (previewResult.NotificationMessage is not null)
{
previewNotificationMessages.Add((chat, previewResult.NotificationMessage));
}
var shouldFetchHistory = previewResult.ShouldFetchHistory ||
(chat.Kind == ChatKind.Channel &&
previewResult.PreviewAt is not null &&
update.Messages.Count == 0 &&
HasListClockTime(update.LastMessageTimeText));
if (allowGenericMediaHistoryFetch &&
shouldFetchHistory &&
previewResult.PreviewAt is { } previewAt &&
!await HasMessageAtOrAfterAsync(db, chat.Id, previewAt, cancellationToken))
{
genericMediaHistoryRequests.Add((
update.ExternalId,
nextWebUrl ?? chat.WebUrl,
previewAt,
IncrementUnread: chat.Kind != ChatKind.Channel));
}
if (previewResult.Changed) if (previewResult.Changed)
{ {
@@ -322,7 +408,15 @@ public sealed class MaxBridgeSyncService(
foreach (var incomingAttachment in DistinctAttachments(incoming.Attachments ?? [])) foreach (var incomingAttachment in DistinctAttachments(incoming.Attachments ?? []))
{ {
var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken); var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken);
message.Attachments.Add(attachment); if (attachment is not null)
{
message.Attachments.Add(attachment);
}
}
if (ShouldDeferMediaOnlyMessage(message, incoming.Attachments ?? []))
{
continue;
} }
db.Messages.Add(message); db.Messages.Add(message);
@@ -344,7 +438,15 @@ public sealed class MaxBridgeSyncService(
{ {
var dto = projection.ToDto(message); var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken); await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
if (sendPushNotifications && message.Direction == MessageDirection.Incoming) if (sendPushNotifications && message.Direction == MessageDirection.Incoming && chat.Kind != ChatKind.Channel)
{
await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken);
}
}
foreach (var (chat, message) in previewNotificationMessages)
{
if (sendPushNotifications && message.Direction == MessageDirection.Incoming && chat.Kind != ChatKind.Channel)
{ {
await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken); await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken);
} }
@@ -366,6 +468,29 @@ public sealed class MaxBridgeSyncService(
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
} }
foreach (var request in genericMediaHistoryRequests
.GroupBy(x => x.ExternalChatId, StringComparer.Ordinal)
.Select(x => x.OrderByDescending(item => item.PreviewAt).First()))
{
try
{
var history = await maxBridgeClient.FetchChatHistoryAsync(request.ExternalChatId, request.ChatUrl, cancellationToken);
if (history is not null)
{
changed += await ApplyUpdatesAsync(
[history],
incrementUnread && request.IncrementUnread,
sendPushNotifications,
cancellationToken,
allowGenericMediaHistoryFetch: false);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "MAX media preview history fetch failed for {ExternalChatId}.", request.ExternalChatId);
}
}
return changed; return changed;
} }
@@ -374,17 +499,19 @@ public sealed class MaxBridgeSyncService(
Chat chat, Chat chat,
bool chatWasCreated, bool chatWasCreated,
bool incrementUnread, bool incrementUnread,
LastKnownMessageSnapshot? lastKnownMessage) LastKnownMessageSnapshot? lastKnownMessage,
bool createPreviewMessages,
bool incrementGenericMediaUnread)
{ {
var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview); var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview);
if (string.IsNullOrWhiteSpace(preview)) if (string.IsNullOrWhiteSpace(preview))
{ {
return new ListPreviewApplyResult(false, null); return new ListPreviewApplyResult(false, null, null, false, null);
} }
if (IsPresencePreview(preview)) if (IsPresencePreview(preview))
{ {
return new ListPreviewApplyResult(false, null); return new ListPreviewApplyResult(false, null, null, false, null);
} }
var previousPreview = chat.LastMessagePreview; var previousPreview = chat.LastMessagePreview;
@@ -392,15 +519,19 @@ public sealed class MaxBridgeSyncService(
var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt); var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt);
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt) if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt)
{ {
return new ListPreviewApplyResult(false, null); return new ListPreviewApplyResult(false, null, null, false, null);
} }
var isGenericMediaPreview = MessageTextSanitizer.IsGenericMediaLabel(preview);
var shouldFetchHistory = isGenericMediaPreview &&
update.Messages.Count == 0 &&
HasListClockTime(update.LastMessageTimeText);
var samePreview = string.Equals(previousPreview, preview, StringComparison.Ordinal); var samePreview = string.Equals(previousPreview, preview, StringComparison.Ordinal);
var samePreviewAt = previousPreviewAt is not null && var samePreviewAt = previousPreviewAt is not null &&
Math.Abs((previousPreviewAt.Value - previewAt).TotalSeconds) < 1; Math.Abs((previousPreviewAt.Value - previewAt).TotalSeconds) < 1;
if (samePreview && samePreviewAt) if (samePreview && samePreviewAt)
{ {
return new ListPreviewApplyResult(false, null); return new ListPreviewApplyResult(false, null, null, shouldFetchHistory, previewAt);
} }
chat.LastMessagePreview = preview; chat.LastMessagePreview = preview;
@@ -408,8 +539,7 @@ public sealed class MaxBridgeSyncService(
chat.UpdatedAt = DateTimeOffset.UtcNow; chat.UpdatedAt = DateTimeOffset.UtcNow;
var echoesKnownOutgoing = IsKnownOutgoingPreviewEcho(lastKnownMessage, preview, previewAt); var echoesKnownOutgoing = IsKnownOutgoingPreviewEcho(lastKnownMessage, preview, previewAt);
var shouldNotifyCandidate = !chatWasCreated && var shouldNotifyCandidate = (chatWasCreated || !string.IsNullOrWhiteSpace(previousPreview)) &&
!string.IsNullOrWhiteSpace(previousPreview) &&
!echoesKnownOutgoing && !echoesKnownOutgoing &&
update.LastMessageIsOutgoing != true && update.LastMessageIsOutgoing != true &&
update.Messages.Count == 0 && update.Messages.Count == 0 &&
@@ -417,15 +547,15 @@ public sealed class MaxBridgeSyncService(
var shouldNotify = shouldNotifyCandidate && var shouldNotify = shouldNotifyCandidate &&
RememberPreviewNotification(chat.Id, preview, previewAt); RememberPreviewNotification(chat.Id, preview, previewAt);
if (incrementUnread && shouldNotify) if (incrementUnread && shouldNotify && (!isGenericMediaPreview || incrementGenericMediaUnread))
{ {
chat.UnreadCount++; chat.UnreadCount++;
} }
var message = shouldNotify var message = shouldNotify && !isGenericMediaPreview && createPreviewMessages
? CreatePreviewMessage(chat, preview, previewAt) ? CreatePreviewMessage(chat, preview, previewAt)
: null; : null;
return new ListPreviewApplyResult(true, message); return new ListPreviewApplyResult(true, message, null, shouldFetchHistory, previewAt);
} }
private static bool IsKnownOutgoingPreviewEcho( private static bool IsKnownOutgoingPreviewEcho(
@@ -604,7 +734,12 @@ public sealed class MaxBridgeSyncService(
text.Contains("file", StringComparison.Ordinal); text.Contains("file", StringComparison.Ordinal);
} }
private sealed record ListPreviewApplyResult(bool Changed, Message? Message); private sealed record ListPreviewApplyResult(
bool Changed,
Message? Message,
Message? NotificationMessage,
bool ShouldFetchHistory,
DateTimeOffset? PreviewAt);
private sealed record MessageMergeResult(bool Changed, IReadOnlyList<MessageAttachment> AddedAttachments); private sealed record MessageMergeResult(bool Changed, IReadOnlyList<MessageAttachment> AddedAttachments);
@@ -691,6 +826,22 @@ public sealed class MaxBridgeSyncService(
Math.Abs((candidate.SentAt.ToUniversalTime() - previewMessage.SentAt.ToUniversalTime()).TotalMinutes) <= 30); Math.Abs((candidate.SentAt.ToUniversalTime() - previewMessage.SentAt.ToUniversalTime()).TotalMinutes) <= 30);
} }
private static async Task<bool> HasMessageAtOrAfterAsync(
QMaxDbContext db,
Guid chatId,
DateTimeOffset previewAt,
CancellationToken cancellationToken)
{
var cutoff = previewAt.ToUniversalTime().AddSeconds(-1);
var sentAtValues = await db.Messages
.AsNoTracking()
.Where(x => x.ChatId == chatId && x.DeletedAt == null)
.Select(x => x.SentAt)
.ToArrayAsync(cancellationToken);
return sentAtValues.Any(sentAt => sentAt.ToUniversalTime() >= cutoff);
}
private static string PreviewExternalId(Guid chatId, string preview, DateTimeOffset previewAt) private static string PreviewExternalId(Guid chatId, string preview, DateTimeOffset previewAt)
{ {
var previewMinute = previewAt.ToUniversalTime().Ticks / TimeSpan.TicksPerMinute; var previewMinute = previewAt.ToUniversalTime().Ticks / TimeSpan.TicksPerMinute;
@@ -882,12 +1033,25 @@ public sealed class MaxBridgeSyncService(
continue; continue;
} }
if (HasAttachment(knownAttachments, incomingAttachment)) var existingAttachment = FindMatchingAttachment(knownAttachments, incomingAttachment);
if (existingAttachment is not null)
{ {
if (IsUncachedRemotePlaceholder(existingAttachment) &&
RequiresCachedRemoteMedia(existingAttachment.Kind) &&
await TryHydrateRemoteAttachmentAsync(existingAttachment, incomingAttachment, maxBridgeClient, storage, cancellationToken))
{
changed = true;
}
continue; continue;
} }
var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken); var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken);
if (attachment is null)
{
continue;
}
attachment.MessageId = message.Id; attachment.MessageId = message.Id;
db.MessageAttachments.Add(attachment); db.MessageAttachments.Add(attachment);
knownAttachments.Add(attachment); knownAttachments.Add(attachment);
@@ -981,7 +1145,7 @@ public sealed class MaxBridgeSyncService(
return $"fallback:{attachment.Kind}:{attachment.FileName}:{attachment.SortOrder}:{attachment.TextContent?.Length ?? 0}"; return $"fallback:{attachment.Kind}:{attachment.FileName}:{attachment.SortOrder}:{attachment.TextContent?.Length ?? 0}";
} }
private static async Task<MessageAttachment> CreateAttachmentAsync( private static async Task<MessageAttachment?> CreateAttachmentAsync(
MaxAttachmentUpdate incoming, MaxAttachmentUpdate incoming,
IMaxBridgeClient maxBridgeClient, IMaxBridgeClient maxBridgeClient,
IAttachmentStorageService storage, IAttachmentStorageService storage,
@@ -1052,7 +1216,12 @@ public sealed class MaxBridgeSyncService(
} }
catch catch
{ {
// Keep the message visible even if a specific remote media item cannot be cached. // Non-visual files can stay as deferred downloads; inline media must be cached before it is shown.
}
if (RequiresCachedRemoteMedia(kind))
{
return null;
} }
} }
@@ -1069,28 +1238,103 @@ public sealed class MaxBridgeSyncService(
}; };
} }
private static bool HasAttachment(IEnumerable<MessageAttachment> attachments, MaxAttachmentUpdate incoming) private static MessageAttachment? FindMatchingAttachment(IEnumerable<MessageAttachment> attachments, MaxAttachmentUpdate incoming)
{ {
if (!string.IsNullOrWhiteSpace(incoming.ExternalId) && if (!string.IsNullOrWhiteSpace(incoming.ExternalId) &&
attachments.Any(x => string.Equals(x.ExternalId, incoming.ExternalId, StringComparison.Ordinal))) attachments.FirstOrDefault(x => string.Equals(x.ExternalId, incoming.ExternalId, StringComparison.Ordinal)) is { } externalMatch)
{ {
return true; return externalMatch;
} }
if (!string.IsNullOrWhiteSpace(incoming.RemoteUrl) && if (!string.IsNullOrWhiteSpace(incoming.RemoteUrl) &&
attachments.Any(x => string.Equals(x.RemoteUrl, incoming.RemoteUrl, StringComparison.Ordinal))) attachments.FirstOrDefault(x => string.Equals(x.RemoteUrl, incoming.RemoteUrl, StringComparison.Ordinal)) is { } remoteMatch)
{ {
return true; return remoteMatch;
} }
var kind = ResolveKind(incoming) ?? AttachmentKind.File; var kind = ResolveKind(incoming) ?? AttachmentKind.File;
var normalizedFileName = AttachmentStorageService.NormalizeRemoteFileName(incoming.FileName, incoming.ContentType, kind); var normalizedFileName = AttachmentStorageService.NormalizeRemoteFileName(incoming.FileName, incoming.ContentType, kind);
return attachments.Any(x => return attachments.FirstOrDefault(x =>
x.SortOrder == incoming.SortOrder && x.SortOrder == incoming.SortOrder &&
x.Kind == kind && x.Kind == kind &&
string.Equals(x.OriginalFileName, normalizedFileName, StringComparison.OrdinalIgnoreCase)); string.Equals(x.OriginalFileName, normalizedFileName, StringComparison.OrdinalIgnoreCase));
} }
private static bool ShouldDeferMediaOnlyMessage(Message message, IReadOnlyList<MaxAttachmentUpdate> incomingAttachments)
{
return incomingAttachments.Count > 0 &&
message.Attachments.Count == 0 &&
string.IsNullOrWhiteSpace(message.Text) &&
incomingAttachments.Any(attachment => RequiresCachedRemoteMedia(ResolveKind(attachment)));
}
private static bool RequiresCachedRemoteMedia(AttachmentKind? kind)
{
return kind is AttachmentKind.Image or
AttachmentKind.Gif or
AttachmentKind.Video or
AttachmentKind.VoiceNote or
AttachmentKind.Sticker;
}
private static bool IsUncachedRemotePlaceholder(MessageAttachment attachment)
{
return !string.IsNullOrWhiteSpace(attachment.RemoteUrl) &&
attachment.StorageFileName.StartsWith("remote-", StringComparison.Ordinal) &&
(attachment.FileSizeBytes <= 0 || string.IsNullOrWhiteSpace(attachment.Sha256));
}
private static async Task<bool> TryHydrateRemoteAttachmentAsync(
MessageAttachment attachment,
MaxAttachmentUpdate incoming,
IMaxBridgeClient maxBridgeClient,
IAttachmentStorageService storage,
CancellationToken cancellationToken)
{
var remoteUrl = string.IsNullOrWhiteSpace(incoming.RemoteUrl) ? attachment.RemoteUrl : incoming.RemoteUrl;
if (string.IsNullOrWhiteSpace(remoteUrl))
{
return false;
}
var kind = ResolveKind(incoming) ?? attachment.Kind;
try
{
var download = await maxBridgeClient.DownloadMediaAsync(remoteUrl, cancellationToken);
if (download is null)
{
return false;
}
await using (download.Stream)
{
var stored = await storage.SaveRemoteAsync(
incoming.FileName,
download.ContentType,
download.Stream,
download.ContentLength ?? incoming.FileSizeBytes,
kind,
cancellationToken);
attachment.ExternalId = string.IsNullOrWhiteSpace(incoming.ExternalId) ? attachment.ExternalId : incoming.ExternalId;
attachment.OriginalFileName = stored.OriginalFileName;
attachment.StorageFileName = stored.StorageFileName;
attachment.ContentType = stored.ContentType;
attachment.FileSizeBytes = stored.FileSizeBytes;
attachment.Sha256 = stored.Sha256;
attachment.Kind = stored.Kind;
attachment.SortOrder = incoming.SortOrder;
attachment.RemoteUrl = remoteUrl;
}
return true;
}
catch
{
return false;
}
}
private static AttachmentKind? ResolveKind(MaxAttachmentUpdate attachment) private static AttachmentKind? ResolveKind(MaxAttachmentUpdate attachment)
{ {
if (!string.IsNullOrWhiteSpace(attachment.Kind) && if (!string.IsNullOrWhiteSpace(attachment.Kind) &&
@@ -1121,6 +1365,49 @@ public sealed class MaxBridgeSyncService(
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
} }
private static ChatKind? ResolveChatKind(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return Enum.TryParse<ChatKind>(value.Trim(), ignoreCase: true, out var parsed)
? parsed
: null;
}
private static async Task<Chat?> FindDeletedChatTombstoneAsync(
QMaxDbContext db,
MaxChatUpdate update,
string? nextWebUrl,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(update.Title))
{
return null;
}
var deletedChats = db.Chats
.Where(x => x.DeletedAt != null && x.Title == update.Title);
if (!string.IsNullOrWhiteSpace(nextWebUrl))
{
return await deletedChats.FirstOrDefaultAsync(x => x.WebUrl == nextWebUrl, cancellationToken);
}
if (!string.IsNullOrWhiteSpace(update.AvatarUrl))
{
return await deletedChats.FirstOrDefaultAsync(x => x.AvatarUrl == update.AvatarUrl, cancellationToken);
}
var titleMatches = await deletedChats
.OrderByDescending(x => x.DeletedAt)
.Take(2)
.ToListAsync(cancellationToken);
return titleMatches.Count == 1 ? titleMatches[0] : null;
}
private static string? LastMessagePreview(string? text, IEnumerable<MessageAttachment> attachments) private static string? LastMessagePreview(string? text, IEnumerable<MessageAttachment> attachments)
{ {
var orderedAttachments = attachments.OrderBy(x => x.SortOrder).ToArray(); var orderedAttachments = attachments.OrderBy(x => x.SortOrder).ToArray();
@@ -1144,6 +1431,11 @@ public sealed class MaxBridgeSyncService(
}; };
} }
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status)
{
return new MaxBridgeStatusDto(status.Mode, status.IsAuthorized, status.LoginStage, status.Status, status.Url, status.Title, status.LastError, status.UpdatedAt);
}
private sealed record LastKnownMessageSnapshot( private sealed record LastKnownMessageSnapshot(
MessageDirection Direction, MessageDirection Direction,
string? Text, string? Text,
+4 -5
View File
@@ -68,7 +68,7 @@ public sealed class MaxOutboxService(
.Where(chat => IsChatActionDue(chat, now)) .Where(chat => IsChatActionDue(chat, now))
.OrderBy(chat => chat.PendingMaxActionRequestedAt ?? chat.UpdatedAt) .OrderBy(chat => chat.PendingMaxActionRequestedAt ?? chat.UpdatedAt)
.ThenBy(chat => chat.Id) .ThenBy(chat => chat.Id)
.Take(1) .Take(10)
.ToList(); .ToList();
var processed = 0; var processed = 0;
@@ -125,10 +125,9 @@ public sealed class MaxOutboxService(
chat, chat,
(externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken), (externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken),
cancellationToken), cancellationToken),
ChatPendingMaxAction.DeleteChat => await ApplyMaxChatActionAsync( ChatPendingMaxAction.DeleteChat => new MaxActionResult(
chat, false,
(externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken), "Chat deletion is disabled because MAX does not remove chats from the web client."),
cancellationToken),
_ => new MaxActionResult(false, $"Unsupported chat action: {pendingAction.Value}.") _ => new MaxActionResult(false, $"Unsupported chat action: {pendingAction.Value}.")
}; };
} }
@@ -35,6 +35,8 @@ public static class MessageTextSanitizer
{ {
return value?.Trim().ToLowerInvariant() switch return value?.Trim().ToLowerInvariant() switch
{ {
"\u043c\u0435\u0434\u0438\u0430" => true,
"media" => true,
"\u0444\u043e\u0442\u043e" => true, "\u0444\u043e\u0442\u043e" => true,
"photo" => true, "photo" => true,
"image" => true, "image" => true,
+1 -1
View File
@@ -16,7 +16,7 @@
"PairingCode": "", "PairingCode": "",
"MaxPhoneNumber": "", "MaxPhoneNumber": "",
"MaxMode": "Worker", "MaxMode": "Worker",
"MaxWorkerBaseUrl": "http://qmax-max-worker:3001", "MaxWorkerBaseUrl": "http://qmax-pymax-worker:3002",
"MaxUserDataPath": "data/max-profile", "MaxUserDataPath": "data/max-profile",
"MaxHeadless": true, "MaxHeadless": true,
"MaxPollIntervalSeconds": 6, "MaxPollIntervalSeconds": 6,
+312 -57
View File
@@ -112,7 +112,7 @@ public sealed class ApiSmokeTests : IDisposable
using var scope = _factory.Services.CreateScope(); using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>(); var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedChat = await db.Chats.AsNoTracking().SingleAsync(x => x.Id == chat.Id); var storedChat = await db.Chats.AsNoTracking().SingleAsync(x => x.Id == chat.Id);
Assert.Equal("https://web.max.ru/mock/mock-direct", storedChat.WebUrl); Assert.Equal("pymax://mock/mock-direct", storedChat.WebUrl);
} }
[Fact] [Fact]
@@ -655,7 +655,7 @@ public sealed class ApiSmokeTests : IDisposable
} }
[Fact] [Fact]
public async Task BulkDeleteQueuesMaxActionAndOutboxDeletesWithChatUrl() public async Task DeleteChatsIsDisabledAndKeepsChatVisible()
{ {
var bridge = new ResolvingDeleteMaxBridgeClient(); var bridge = new ResolvingDeleteMaxBridgeClient();
using var factory = _factory.WithWebHostBuilder(builder => using var factory = _factory.WithWebHostBuilder(builder =>
@@ -670,12 +670,13 @@ public sealed class ApiSmokeTests : IDisposable
using var client = factory.CreateClient(); using var client = factory.CreateClient();
await LoginAsync(client); await LoginAsync(client);
var chat = await CreateDirectChatAsync(client, "missing-url-chat", "Missing Url"); var externalId = $"missing-url-chat-{Guid.NewGuid():N}";
var chat = await CreateDirectChatAsync(client, externalId, "Missing Url");
var response = await client.PostAsJsonAsync( var response = await client.PostAsJsonAsync(
"/api/chats/delete", "/api/chats/delete",
new ChatBulkActionRequest([chat.Id]), new ChatBulkActionRequest([chat.Id]),
JsonOptions); JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, response); await AssertStatusAsync(HttpStatusCode.BadRequest, response);
Assert.Null(bridge.ResolvedExternalId); Assert.Null(bridge.ResolvedExternalId);
Assert.Empty(bridge.DeleteChatUrls); Assert.Empty(bridge.DeleteChatUrls);
@@ -684,25 +685,56 @@ public sealed class ApiSmokeTests : IDisposable
{ {
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>(); var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
Assert.NotNull(stored.DeletedAt); Assert.Null(stored.DeletedAt);
Assert.Equal(ChatPendingMaxAction.DeleteChat, stored.PendingMaxAction); Assert.Null(stored.PendingMaxAction);
Assert.NotNull(stored.PendingMaxActionRequestedAt); Assert.Null(stored.PendingMaxActionRequestedAt);
} }
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions); var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.DoesNotContain(chats!, x => x.Id == chat.Id); Assert.Contains(chats!, x => x.Id == chat.Id);
}
Assert.True(await ProcessOutboxAsync(factory) >= 1); [Fact]
Assert.Equal("https://max.test/chat/missing-url-chat", bridge.DeleteChatUrl); public async Task DeleteChatsRejectsBulkRequestWithoutPendingActions()
{
await using (var scope = factory.Services.CreateAsyncScope()) var bridge = new ResolvingDeleteMaxBridgeClient();
using var factory = _factory.WithWebHostBuilder(builder =>
{ {
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>(); builder.ConfigureTestServices(services =>
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); {
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.AddSingleton<IMaxBridgeClient>(bridge);
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var suffix = Guid.NewGuid().ToString("N");
var first = await CreateDirectChatAsync(client, $"bulk-delete-one-{suffix}", "Bulk Delete One");
var second = await CreateDirectChatAsync(client, $"bulk-delete-two-{suffix}", "Bulk Delete Two");
var third = await CreateDirectChatAsync(client, $"bulk-delete-three-{suffix}", "Bulk Delete Three");
var response = await client.PostAsJsonAsync(
"/api/chats/delete",
new ChatBulkActionRequest([first.Id, second.Id, third.Id]),
JsonOptions);
await AssertStatusAsync(HttpStatusCode.BadRequest, response);
Assert.Empty(bridge.DeleteChatUrls);
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedChats = await db.Chats
.Where(x => x.Id == first.Id || x.Id == second.Id || x.Id == third.Id)
.ToListAsync();
Assert.All(storedChats, stored =>
{
Assert.Null(stored.DeletedAt);
Assert.Null(stored.PendingMaxAction); Assert.Null(stored.PendingMaxAction);
Assert.Equal("https://max.test/chat/missing-url-chat", stored.WebUrl); });
Assert.NotNull(stored.DeletedAt);
} var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.Equal(3, chats!.Count(x => x.Id == first.Id || x.Id == second.Id || x.Id == third.Id));
} }
[Fact] [Fact]
@@ -773,6 +805,40 @@ public sealed class ApiSmokeTests : IDisposable
Assert.StartsWith("preview:", backfilled.ExternalId); Assert.StartsWith("preview:", backfilled.ExternalId);
} }
[Fact]
public async Task GetMessagesDoesNotBackfillGenericMediaPreviewWhenAttachmentRowIsMissing()
{
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var marker = Guid.NewGuid().ToString("N");
var chat = await CreateDirectChatAsync(client, $"generic-media-preview-chat-{marker}", "Generic Media Preview");
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
stored.LastMessagePreview = "\u0424\u043e\u0442\u043e";
stored.LastMessageAt = DateTimeOffset.UtcNow;
stored.UnreadCount = 1;
await db.SaveChangesAsync();
}
var messages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
Assert.NotNull(messages);
Assert.DoesNotContain(messages!, x => x.ExternalId?.StartsWith("preview:", StringComparison.Ordinal) == true);
await using var verifyScope = factory.Services.CreateAsyncScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<QMaxDbContext>();
Assert.False(await verifyDb.Messages.AnyAsync(x => x.ChatId == chat.Id));
}
[Fact] [Fact]
public async Task SyncCreatesMessageRowForFreshPreviewOnlyIncomingUpdate() public async Task SyncCreatesMessageRowForFreshPreviewOnlyIncomingUpdate()
{ {
@@ -837,10 +903,133 @@ public sealed class ApiSmokeTests : IDisposable
} }
[Fact] [Fact]
public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure() public async Task SyncCreatesNewChatAndPreviewMessageForFreshIncomingListUpdate()
{ {
const string externalId = "stale-menu-url-chat"; var pushNotifications = new RecordingPushNotificationService();
const string staleUrl = "https://max.test/chat/wrong-chat"; var marker = Guid.NewGuid().ToString("N");
var externalId = $"fresh-preview-chat-{marker}";
var title = "Fresh Preview Chat";
var previewText = $"fresh incoming {marker}";
var now = DateTimeOffset.UtcNow;
var bridge = new ResolvingDeleteMaxBridgeClient
{
Updates =
[
new MaxChatUpdate(
externalId,
title,
null,
now,
[],
previewText,
false,
now,
now.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"),
$"https://max.test/chat/{externalId}")
]
};
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.RemoveAll<IPushNotificationService>();
services.AddSingleton<IMaxBridgeClient>(bridge);
services.AddSingleton<IPushNotificationService>(pushNotifications);
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync);
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var chat = await db.Chats.SingleAsync(x => x.ExternalId == externalId);
Assert.Equal(title, chat.Title);
Assert.Equal(previewText, chat.LastMessagePreview);
Assert.Equal(1, chat.UnreadCount);
var message = await db.Messages.SingleAsync(x => x.ChatId == chat.Id);
Assert.Equal(previewText, message.Text);
Assert.Equal(MessageDirection.Incoming, message.Direction);
Assert.StartsWith("preview:", message.ExternalId);
var previewPush = Assert.Single(pushNotifications.SentMessages);
Assert.Equal(chat.Id, previewPush.Chat.Id);
Assert.Equal(previewText, previewPush.Message.Text);
}
[Fact]
public async Task SyncDoesNotCreateTextOnlyMessageForGenericMediaPreviewOnlyIncomingUpdate()
{
var pushNotifications = new RecordingPushNotificationService();
var marker = Guid.NewGuid().ToString("N");
var externalId = $"generic-media-sync-chat-{marker}";
var now = DateTimeOffset.UtcNow;
var bridge = new ResolvingDeleteMaxBridgeClient
{
Updates =
[
new MaxChatUpdate(
externalId,
"Generic Media Sync",
null,
now,
[],
"\u0424\u043e\u0442\u043e",
false,
now,
now.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"),
$"https://max.test/chat/{externalId}")
]
};
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.RemoveAll<IPushNotificationService>();
services.AddSingleton<IMaxBridgeClient>(bridge);
services.AddSingleton<IPushNotificationService>(pushNotifications);
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var chat = await CreateDirectChatAsync(client, externalId, "Generic Media Sync");
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
stored.LastMessagePreview = "previous preview";
stored.LastMessageAt = now.AddMinutes(-5);
await db.SaveChangesAsync();
}
var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync);
await using var verifyScope = factory.Services.CreateAsyncScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedChat = await verifyDb.Chats.SingleAsync(x => x.Id == chat.Id);
Assert.Equal("\u041c\u0435\u0434\u0438\u0430", storedChat.LastMessagePreview);
Assert.False(await verifyDb.Messages.AnyAsync(x =>
x.ChatId == chat.Id &&
x.ExternalId != null &&
x.ExternalId.StartsWith("preview:")));
Assert.Empty(pushNotifications.SentMessages);
}
[Fact]
public async Task DeleteChatsDoesNotRefreshStaleChatUrlOrHideChat()
{
var suffix = Guid.NewGuid().ToString("N");
var externalId = $"stale-menu-url-chat-{suffix}";
var staleUrl = $"https://max.test/chat/wrong-chat-{suffix}";
var bridge = new ResolvingDeleteMaxBridgeClient(staleUrl); var bridge = new ResolvingDeleteMaxBridgeClient(staleUrl);
using var factory = _factory.WithWebHostBuilder(builder => using var factory = _factory.WithWebHostBuilder(builder =>
{ {
@@ -867,28 +1056,26 @@ public sealed class ApiSmokeTests : IDisposable
"/api/chats/delete", "/api/chats/delete",
new ChatBulkActionRequest([chat.Id]), new ChatBulkActionRequest([chat.Id]),
JsonOptions); JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, response); await AssertStatusAsync(HttpStatusCode.BadRequest, response);
Assert.Empty(bridge.DeleteChatUrls); Assert.Empty(bridge.DeleteChatUrls);
Assert.Null(bridge.ResolvedExternalId);
Assert.True(await ProcessOutboxAsync(factory) >= 1);
Assert.Equal(externalId, bridge.ResolvedExternalId);
Assert.Equal([staleUrl, $"https://max.test/chat/{externalId}"], bridge.DeleteChatUrls);
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions); var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.DoesNotContain(chats!, x => x.Id == chat.Id); Assert.Contains(chats!, x => x.Id == chat.Id);
await using (var scope = factory.Services.CreateAsyncScope()) await using (var scope = factory.Services.CreateAsyncScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>(); var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
Assert.Null(stored.PendingMaxAction); Assert.Null(stored.PendingMaxAction);
Assert.Equal($"https://max.test/chat/{externalId}", stored.WebUrl); Assert.Null(stored.DeletedAt);
Assert.Equal(staleUrl, stored.WebUrl);
} }
} }
[Fact] [Fact]
public async Task OutboxSendsQueuedMessagesBeforePendingChatActions() public async Task OutboxSendsQueuedMessagesBeforePendingClearHistoryActions()
{ {
var bridge = new ResolvingDeleteMaxBridgeClient(); var bridge = new ResolvingDeleteMaxBridgeClient();
using var factory = _factory.WithWebHostBuilder(builder => using var factory = _factory.WithWebHostBuilder(builder =>
@@ -903,28 +1090,31 @@ public sealed class ApiSmokeTests : IDisposable
using var client = factory.CreateClient(); using var client = factory.CreateClient();
await LoginAsync(client); await LoginAsync(client);
var deleteChat = await CreateDirectChatAsync(client, "delete-after-send-chat", "Delete After Send"); var clearChat = await CreateDirectChatAsync(client, "clear-after-send-chat", "Clear After Send");
var deleteResponse = await client.PostAsJsonAsync( var clearResponse = await client.PostAsJsonAsync(
"/api/chats/delete", "/api/chats/clear-history",
new ChatBulkActionRequest([deleteChat.Id]), new ChatBulkActionRequest([clearChat.Id]),
JsonOptions); JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse); await AssertStatusAsync(HttpStatusCode.NoContent, clearResponse);
var sendChat = await CreateDirectChatAsync(client, "send-before-delete-chat", "Send Before Delete"); var sendChat = await CreateDirectChatAsync(client, "send-before-clear-chat", "Send Before Clear");
var messageResponse = await client.PostAsJsonAsync( var messageResponse = await client.PostAsJsonAsync(
$"/api/chats/{sendChat.Id}/messages", $"/api/chats/{sendChat.Id}/messages",
new SendMessageRequest("send before chat action"), new SendMessageRequest("send before chat action"),
JsonOptions); JsonOptions);
await AssertStatusAsync(HttpStatusCode.OK, messageResponse); await AssertStatusAsync(HttpStatusCode.OK, messageResponse);
Assert.Equal(2, await ProcessOutboxAsync(factory)); Assert.True(await ProcessOutboxAsync(factory) >= 2);
Assert.Equal(["send:text", "delete"], bridge.OperationOrder); var sendIndex = bridge.OperationOrder.IndexOf("send:text");
var clearIndex = bridge.OperationOrder.IndexOf("clear");
Assert.True(sendIndex >= 0, "Expected a text send operation.");
Assert.True(clearIndex > sendIndex, "Expected text send before pending clear-history action.");
} }
[Fact] [Fact]
public async Task FailedPendingChatActionWaitsBeforeImmediateRetry() public async Task FailedPendingChatActionWaitsBeforeImmediateRetry()
{ {
var bridge = new ResolvingDeleteMaxBridgeClient { AlwaysFailDelete = true }; var bridge = new ResolvingDeleteMaxBridgeClient { AlwaysFailClear = true };
using var factory = _factory.WithWebHostBuilder(builder => using var factory = _factory.WithWebHostBuilder(builder =>
{ {
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
@@ -937,26 +1127,26 @@ public sealed class ApiSmokeTests : IDisposable
using var client = factory.CreateClient(); using var client = factory.CreateClient();
await LoginAsync(client); await LoginAsync(client);
var chat = await CreateDirectChatAsync(client, "retry-delay-delete-chat", "Retry Delay Delete"); var chat = await CreateDirectChatAsync(client, "retry-delay-clear-chat", "Retry Delay Clear");
var deleteResponse = await client.PostAsJsonAsync( var clearResponse = await client.PostAsJsonAsync(
"/api/chats/delete", "/api/chats/clear-history",
new ChatBulkActionRequest([chat.Id]), new ChatBulkActionRequest([chat.Id]),
JsonOptions); JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse); await AssertStatusAsync(HttpStatusCode.NoContent, clearResponse);
Assert.True(await ProcessOutboxAsync(factory) >= 1); Assert.True(await ProcessOutboxAsync(factory) >= 1);
Assert.Single(bridge.DeleteChatUrls); Assert.Single(bridge.ClearChatUrls);
await ProcessOutboxAsync(factory); await ProcessOutboxAsync(factory);
Assert.Single(bridge.DeleteChatUrls); Assert.Single(bridge.ClearChatUrls);
await using var scope = factory.Services.CreateAsyncScope(); await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>(); var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
Assert.Equal(ChatPendingMaxAction.DeleteChat, stored.PendingMaxAction); Assert.Equal(ChatPendingMaxAction.ClearHistory, stored.PendingMaxAction);
Assert.Equal(1, stored.PendingMaxActionAttempts); Assert.Equal(1, stored.PendingMaxActionAttempts);
Assert.NotNull(stored.PendingMaxActionLastAttemptAt); Assert.NotNull(stored.PendingMaxActionLastAttemptAt);
Assert.Equal("Simulated MAX delete failure.", stored.PendingMaxActionError); Assert.Equal("Simulated MAX clear failure.", stored.PendingMaxActionError);
} }
[Fact] [Fact]
@@ -1016,9 +1206,10 @@ public sealed class ApiSmokeTests : IDisposable
} }
[Fact] [Fact]
public async Task SyncDoesNotRecreateLocallyDeletedChat() public async Task SyncKeepsChatVisibleAfterDisabledDeleteRequest()
{ {
const string externalId = "deleted-sync-chat"; var suffix = Guid.NewGuid().ToString("N");
var externalId = $"deleted-sync-chat-{suffix}";
var sentAt = DateTimeOffset.UtcNow; var sentAt = DateTimeOffset.UtcNow;
var bridge = new ResolvingDeleteMaxBridgeClient var bridge = new ResolvingDeleteMaxBridgeClient
{ {
@@ -1031,7 +1222,7 @@ public sealed class ApiSmokeTests : IDisposable
sentAt, sentAt,
[ [
new MaxMessageUpdate( new MaxMessageUpdate(
"deleted-sync-message", $"deleted-sync-message-{suffix}",
"remote-user", "remote-user",
"Deleted Remote", "Deleted Remote",
false, false,
@@ -1062,24 +1253,88 @@ public sealed class ApiSmokeTests : IDisposable
"/api/chats/delete", "/api/chats/delete",
new ChatBulkActionRequest([chat.Id]), new ChatBulkActionRequest([chat.Id]),
JsonOptions); JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse); await AssertStatusAsync(HttpStatusCode.BadRequest, deleteResponse);
var sync = await client.PostAsync("/api/max/sync", null); var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync); await AssertStatusAsync(HttpStatusCode.OK, sync);
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions); var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.DoesNotContain(chats!, x => x.ExternalId == externalId); Assert.Contains(chats!, x => x.ExternalId == externalId);
await using var scope = factory.Services.CreateAsyncScope(); await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>(); var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.Include(x => x.Messages).SingleAsync(x => x.Id == chat.Id); var stored = await db.Chats.Include(x => x.Messages).SingleAsync(x => x.Id == chat.Id);
Assert.NotNull(stored.DeletedAt); Assert.Null(stored.DeletedAt);
Assert.Empty(stored.Messages); Assert.Null(stored.PendingMaxAction);
stored.PendingMaxAction = null; Assert.Contains(stored.Messages, x => x.ExternalId == $"deleted-sync-message-{suffix}");
stored.PendingMaxActionRequestedAt = null; }
stored.PendingMaxActionLastAttemptAt = null;
stored.PendingMaxActionError = null; [Fact]
await db.SaveChangesAsync(); public async Task SyncUpdatesVisibleChatWhenExternalIdChangesButWebUrlMatches()
{
var suffix = Guid.NewGuid().ToString("N");
var oldExternalId = $"deleted-sync-old-external-{suffix}";
var newExternalId = $"deleted-sync-new-external-{suffix}";
var title = $"Visible WebUrl Match {suffix}";
var chatUrl = $"https://max.test/chat/visible-web-url-match-{suffix}";
var sentAt = DateTimeOffset.UtcNow;
var bridge = new ResolvingDeleteMaxBridgeClient
{
Updates =
[
new MaxChatUpdate(
newExternalId,
title,
null,
sentAt,
[],
"remote hello after external id change",
false,
sentAt,
sentAt.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"),
chatUrl)
]
};
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.AddSingleton<IMaxBridgeClient>(bridge);
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var chat = await CreateDirectChatAsync(client, oldExternalId, title);
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
stored.WebUrl = chatUrl;
await db.SaveChangesAsync();
}
var deleteResponse = await client.PostAsJsonAsync(
"/api/chats/delete",
new ChatBulkActionRequest([chat.Id]),
JsonOptions);
await AssertStatusAsync(HttpStatusCode.BadRequest, deleteResponse);
var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync);
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.Contains(chats!, x => x.Title == title);
await using var verifyScope = factory.Services.CreateAsyncScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedChats = await verifyDb.Chats.Where(x => x.Title == title).ToListAsync();
var visibleChat = Assert.Single(storedChats);
Assert.Equal(newExternalId, visibleChat.ExternalId);
Assert.Null(visibleChat.DeletedAt);
Assert.Null(visibleChat.PendingMaxAction);
} }
[Fact] [Fact]
-4
View File
@@ -1,4 +0,0 @@
node_modules
npm-debug.log
.env
data
-12
View File
@@ -1,12 +0,0 @@
FROM mcr.microsoft.com/playwright:v1.61.0-noble
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
COPY src ./src
ENV NODE_ENV=production
ENV PORT=3001
EXPOSE 3001
CMD ["npm", "start"]
-933
View File
@@ -1,933 +0,0 @@
{
"name": "qmax-max-worker",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qmax-max-worker",
"version": "0.1.0",
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"playwright": "1.61.0"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}
-15
View File
@@ -1,15 +0,0 @@
{
"name": "qmax-max-worker",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"playwright": "1.61.0"
}
}
-5942
View File
File diff suppressed because it is too large Load Diff