Compare commits

..
10 Commits
38 changed files with 3259 additions and 8198 deletions
+2
View File
@@ -23,6 +23,8 @@ data/
!server/QMax.Api/Data/ !server/QMax.Api/Data/
!server/QMax.Api/Data/** !server/QMax.Api/Data/**
storage/ storage/
!server/QMax.Api/Infrastructure/Storage/
!server/QMax.Api/Infrastructure/Storage/**
secrets/ secrets/
.env .env
.env.* .env.*
+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.
+2 -2
View File
@@ -22,8 +22,8 @@ android {
applicationId = "xyz.kusoft.qmax" applicationId = "xyz.kusoft.qmax"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 51 versionCode = 54
versionName = "0.1.50" versionName = "0.1.53"
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"") buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"") buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
+1
View File
@@ -2,6 +2,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
File diff suppressed because it is too large Load Diff
@@ -26,7 +26,10 @@ import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.AuthResponse import xyz.kusoft.qmax.core.model.AuthResponse
import xyz.kusoft.qmax.core.model.ChatDto import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ChatPresenceDto import xyz.kusoft.qmax.core.model.ChatPresenceDto
import xyz.kusoft.qmax.core.model.ContactDto
import xyz.kusoft.qmax.core.model.PhoneContactDto
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
@@ -90,8 +93,26 @@ class QMaxRepository(
return chats return chats
} }
suspend fun createChat(session: QMaxSession, externalId: String, title: String): ChatDto { suspend fun contacts(session: QMaxSession): List<ContactDto> {
val chat = withFreshSession(session) { api.createDirect(it.serverUrl, it.accessToken, externalId, title) } return withFreshSession(session) { api.contacts(it.serverUrl, it.accessToken) }
}
suspend fun importContacts(session: QMaxSession, contacts: List<PhoneContactDto>): List<ContactDto> {
return withFreshSession(session) { api.importContacts(it.serverUrl, it.accessToken, contacts) }
}
suspend fun addContact(session: QMaxSession, userId: String): ContactDto {
return withFreshSession(session) { api.addContact(it.serverUrl, it.accessToken, userId) }
}
suspend fun removeContact(session: QMaxSession, userId: String) {
withFreshSession(session) { api.removeContact(it.serverUrl, it.accessToken, userId) }
}
suspend fun createChat(session: QMaxSession, externalId: String, title: String, avatarUrl: String? = null): ChatDto {
val chat = withFreshSession(session) {
api.createDirect(it.serverUrl, it.accessToken, externalId, title, avatarUrl)
}
val updatedChats = (cachedChats(session).filterNot { it.id == chat.id } + chat) val updatedChats = (cachedChats(session).filterNot { it.id == chat.id } + chat)
.let(::orderedChats) .let(::orderedChats)
messageCache.saveChats(session, updatedChats) messageCache.saveChats(session, updatedChats)
@@ -136,6 +157,15 @@ class QMaxRepository(
} }
} }
suspend fun deleteChats(session: QMaxSession, chatIds: Collection<String>) {
val ids = chatIds.filter(String::isNotBlank).distinct()
if (ids.isEmpty()) return
withFreshSession(session) { api.deleteChats(it.serverUrl, it.accessToken, ids) }
val remainingChats = cachedChats(session).filterNot { it.id in ids }
messageCache.saveChats(session, orderedChats(remainingChats))
messageCache.clearMessages(session, 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) }
@@ -309,6 +339,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")
@@ -49,6 +49,30 @@ data class ChatPresenceDto(
val updatedAt: String? = null val updatedAt: String? = null
) )
@Serializable
data class ContactDto(
val userId: String,
val externalChatId: String,
val displayName: String,
val avatarUrl: String? = null,
val phoneNumber: String? = null,
val status: String? = null,
val isSavedContact: Boolean = false
)
@Serializable
data class PhoneContactDto(
val phoneNumber: String,
val firstName: String,
val lastName: String? = null
)
@Serializable
data class ImportPhoneContactsRequest(val contacts: List<PhoneContactDto>)
@Serializable
data class ContactUserRequest(val userId: String)
@Serializable @Serializable
data class MessageDto( data class MessageDto(
val id: String, val id: String,
@@ -148,6 +172,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,
@@ -26,15 +26,21 @@ import xyz.kusoft.qmax.core.model.ChatBulkActionRequest
import xyz.kusoft.qmax.core.model.ChatDto import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ChatPresenceDto import xyz.kusoft.qmax.core.model.ChatPresenceDto
import xyz.kusoft.qmax.core.model.CreateDirectChatRequest import xyz.kusoft.qmax.core.model.CreateDirectChatRequest
import xyz.kusoft.qmax.core.model.ContactDto
import xyz.kusoft.qmax.core.model.ContactUserRequest
import xyz.kusoft.qmax.core.model.DeviceLoginRequest import xyz.kusoft.qmax.core.model.DeviceLoginRequest
import xyz.kusoft.qmax.core.model.EditMessageRequest import xyz.kusoft.qmax.core.model.EditMessageRequest
import xyz.kusoft.qmax.core.model.ForwardMessageRequest import xyz.kusoft.qmax.core.model.ForwardMessageRequest
import xyz.kusoft.qmax.core.model.MarkChatReadRequest import xyz.kusoft.qmax.core.model.MarkChatReadRequest
import xyz.kusoft.qmax.core.model.ImportPhoneContactsRequest
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.PhoneContactDto
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
@@ -75,8 +81,30 @@ class QMaxApi {
return get(sessionServerUrl, "/api/chats", token) return get(sessionServerUrl, "/api/chats", token)
} }
suspend fun createDirect(sessionServerUrl: String, token: String, externalChatId: String, title: String): ChatDto { suspend fun contacts(sessionServerUrl: String, token: String): List<ContactDto> {
return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title)) return get(sessionServerUrl, "/api/contacts", token)
}
suspend fun importContacts(sessionServerUrl: String, token: String, contacts: List<PhoneContactDto>): List<ContactDto> {
return post(sessionServerUrl, "/api/contacts/import", token, ImportPhoneContactsRequest(contacts))
}
suspend fun addContact(sessionServerUrl: String, token: String, userId: String): ContactDto {
return post(sessionServerUrl, "/api/contacts/add", token, ContactUserRequest(userId))
}
suspend fun removeContact(sessionServerUrl: String, token: String, userId: String) {
postNoContent(sessionServerUrl, "/api/contacts/remove", token, ContactUserRequest(userId))
}
suspend fun createDirect(
sessionServerUrl: String,
token: String,
externalChatId: String,
title: String,
avatarUrl: String? = null
): ChatDto {
return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title, avatarUrl))
} }
suspend fun messages(sessionServerUrl: String, token: String, chatId: String, sync: Boolean = false): List<MessageDto> { suspend fun messages(sessionServerUrl: String, token: String, chatId: String, sync: Boolean = false): List<MessageDto> {
@@ -91,6 +119,10 @@ 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)
@@ -187,6 +219,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 ->
@@ -16,9 +16,12 @@ import xyz.kusoft.qmax.BuildConfig
import xyz.kusoft.qmax.core.QMaxRepository import xyz.kusoft.qmax.core.QMaxRepository
import xyz.kusoft.qmax.core.model.AttachmentDto import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.ChatDto import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ContactDto
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
import xyz.kusoft.qmax.core.model.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.PhoneContactDto
import xyz.kusoft.qmax.core.model.QMaxSession import xyz.kusoft.qmax.core.model.QMaxSession
import xyz.kusoft.qmax.core.network.QMaxHttpException import xyz.kusoft.qmax.core.network.QMaxHttpException
import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient
@@ -31,10 +34,16 @@ data class QMaxUiState(
val pairingCode: String = BuildConfig.QMAX_DEFAULT_PAIRING_CODE, val pairingCode: String = BuildConfig.QMAX_DEFAULT_PAIRING_CODE,
val session: QMaxSession? = null, val session: QMaxSession? = null,
val chats: List<ChatDto> = emptyList(), val chats: List<ChatDto> = emptyList(),
val contacts: List<ContactDto> = emptyList(),
val contactsLoading: Boolean = false,
val contactsError: String? = null,
val selectedChat: ChatDto? = null, val selectedChat: ChatDto? = null,
val messages: List<MessageDto> = emptyList(), val messages: List<MessageDto> = emptyList(),
val chatSearchResults: List<MessageDto> = emptyList(), val chatSearchResults: List<MessageDto> = emptyList(),
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 +81,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
@@ -99,19 +110,25 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
serverUrl = session?.serverUrl ?: state.value.serverUrl, serverUrl = session?.serverUrl ?: state.value.serverUrl,
cachedImagePaths = if (sessionChanged) emptyMap() else state.value.cachedImagePaths, cachedImagePaths = if (sessionChanged) emptyMap() else state.value.cachedImagePaths,
cachedAvatarPaths = if (sessionChanged) emptyMap() else state.value.cachedAvatarPaths, cachedAvatarPaths = if (sessionChanged) emptyMap() else state.value.cachedAvatarPaths,
contacts = if (sessionChanged) emptyList() else state.value.contacts,
contactsLoading = if (sessionChanged) false else state.value.contactsLoading,
contactsError = if (sessionChanged) null else state.value.contactsError,
selectedChatIds = if (sessionChanged) emptySet() else state.value.selectedChatIds selectedChatIds = if (sessionChanged) emptySet() else state.value.selectedChatIds
) )
if (session != null) { if (session != null) {
autoLoginJob?.cancel() autoLoginJob?.cancel()
restoreCachedChats(session) restoreCachedChats(session)
loadChats() loadChats()
loadContacts()
connectRealtime(session) connectRealtime(session)
registerPushDevice(session) registerPushDevice(session)
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
@@ -160,6 +177,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)
@@ -203,6 +275,36 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
fun loadChats() = refreshChats() fun loadChats() = refreshChats()
fun loadContacts() {
val session = state.value.session ?: return
viewModelScope.launch {
state.value = state.value.copy(contactsLoading = true, contactsError = null)
runCatching { repository.contacts(session) }
.onSuccess { contacts ->
if (state.value.session?.serverUrl == session.serverUrl &&
state.value.session?.userName == session.userName
) {
state.value = state.value.copy(
contacts = contacts,
contactsLoading = false,
contactsError = null
)
}
}
.onFailure { error ->
if (error is CancellationException) throw error
if (state.value.session?.serverUrl == session.serverUrl &&
state.value.session?.userName == session.userName
) {
state.value = state.value.copy(
contactsLoading = false,
contactsError = error.message ?: "Не удалось загрузить контакты"
)
}
}
}
}
private suspend fun restoreCachedChats(session: QMaxSession) { private suspend fun restoreCachedChats(session: QMaxSession) {
runCatching { runCatching {
repository.cachedChats(session) repository.cachedChats(session)
@@ -303,6 +405,80 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
loadChats() loadChats()
} }
fun openContact(contact: ContactDto) = launchLoading(false) {
val session = requireSession()
val existing = state.value.chats.firstOrNull { it.externalId == contact.externalChatId }
val chat = existing ?: repository.createChat(
session,
contact.externalChatId,
contact.displayName,
contact.avatarUrl
)
if (existing == null) {
state.value = state.value.copy(chats = normalizeChats(state.value.chats + chat))
}
openChat(chat)
loadChats()
}
fun reportContactsPermissionDenied() {
state.value = state.value.copy(
contactsLoading = false,
contactsError = "Разрешите QMAX доступ к контактам телефона и обновите список"
)
}
fun importPhoneContacts(contacts: List<PhoneContactDto>) = viewModelScope.launch {
state.value = state.value.copy(contactsLoading = true, contactsError = null, error = null)
if (contacts.isEmpty()) {
state.value = state.value.copy(
contacts = emptyList(),
contactsLoading = false,
contactsError = "В телефонной книге нет контактов с номерами"
)
return@launch
}
try {
val session = requireSession()
val imported = repository.importContacts(session, contacts)
state.value = state.value.copy(
contacts = imported.sortedBy { it.displayName.lowercase() },
contactsLoading = false,
contactsError = if (imported.isEmpty()) {
"MAX не сопоставил ни один из ${contacts.size} номеров телефонной книги"
} else {
null
},
updateMessage = "MAX сопоставил контактов: ${imported.size}"
)
} catch (error: Throwable) {
if (error is CancellationException) throw error
Log.w(LogTag, "Phone contacts import failed", error)
state.value = state.value.copy(
contactsLoading = false,
contactsError = error.message ?: "Не удалось синхронизировать контакты"
)
}
}
fun addContact(contact: ContactDto) = launchLoading(false) {
val session = requireSession()
val updated = repository.addContact(session, contact.userId)
state.value = state.value.copy(
contacts = state.value.contacts.map { if (it.userId == updated.userId) updated else it }
)
}
fun removeContact(contact: ContactDto) = launchLoading(false) {
val session = requireSession()
repository.removeContact(session, contact.userId)
state.value = state.value.copy(
contacts = state.value.contacts.map {
if (it.userId == contact.userId) it.copy(isSavedContact = false) else it
}
)
}
fun openChat(chat: ChatDto) { fun openChat(chat: ChatDto) {
saveCurrentDraft() saveCurrentDraft()
chatSearchJob?.cancel() chatSearchJob?.cancel()
@@ -433,6 +609,17 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
refreshChats(showLoading = false) refreshChats(showLoading = false)
} }
fun deleteSelectedChats() = launchLoading(false) {
val session = requireSession()
val ids = state.value.selectedChatIds
if (ids.isEmpty()) return@launchLoading
repository.deleteChats(session, ids)
state.value = state.value.copy(
chats = state.value.chats.filterNot { it.id in ids },
selectedChatIds = emptySet()
)
}
fun loadMessages(showLoading: Boolean = true, forceHydrate: Boolean = false) = launchLoading(showLoading) { fun loadMessages(showLoading: Boolean = true, forceHydrate: Boolean = false) = launchLoading(showLoading) {
if (!showLoading && state.value.sendingMessage) { if (!showLoading && state.value.sendingMessage) {
return@launchLoading return@launchLoading
@@ -687,19 +874,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 {
@@ -830,15 +1018,23 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
state.value = state.value.copy(forwardTarget = null) state.value = state.value.copy(forwardTarget = null)
} }
fun forwardTo(chat: ChatDto) = launchLoading(false) { fun forwardTo(targetChats: List<ChatDto>) = launchLoading(false) {
val session = requireSession() val session = requireSession()
val source = state.value.forwardTarget ?: return@launchLoading val source = state.value.forwardTarget ?: return@launchLoading
val forwarded = repository.forwardMessage(session, source.chatId, source.id, chat.id) val targets = targetChats.distinctBy { it.id }
if (targets.isEmpty()) return@launchLoading
val forwardedMessages = targets.map { chat ->
repository.forwardMessage(session, source.chatId, source.id, chat.id)
}
val current = state.value val current = state.value
state.value = if (current.selectedChat?.id == chat.id) { val openedChatId = current.selectedChat?.id
val openedChatForwardedMessages = forwardedMessages.filter { it.chatId == openedChatId }
state.value = if (openedChatForwardedMessages.isNotEmpty()) {
current.copy( current.copy(
forwardTarget = null, forwardTarget = null,
messages = (current.messages.filterNot { it.id == forwarded.id } + forwarded).sortedBy { it.sentAt } messages = (current.messages.filterNot { existing ->
openedChatForwardedMessages.any { it.id == existing.id }
} + openedChatForwardedMessages).sortedBy { it.sentAt }
) )
} else { } else {
current.copy(forwardTarget = null) current.copy(forwardTarget = null)
@@ -1044,6 +1240,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 {
@@ -1053,7 +1271,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)
} }
@@ -1245,6 +1464,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
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 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;
@@ -52,3 +52,16 @@ public sealed record CreateDirectChatRequest(string ExternalChatId, string Title
public sealed record ChatBulkActionRequest(IReadOnlyList<Guid> ChatIds); public sealed record ChatBulkActionRequest(IReadOnlyList<Guid> ChatIds);
public sealed record ChatPresenceDto(bool IsTyping, string? StatusText, DateTimeOffset UpdatedAt); public sealed record ChatPresenceDto(bool IsTyping, string? StatusText, DateTimeOffset UpdatedAt);
public sealed record ContactDto(
string UserId,
string ExternalChatId,
string DisplayName,
string? AvatarUrl,
string? PhoneNumber,
string? Status,
bool IsSavedContact);
public sealed record PhoneContactDto(string PhoneNumber, string FirstName, string? LastName = null);
public sealed record ImportPhoneContactsRequest(IReadOnlyList<PhoneContactDto> Contacts);
public sealed record ContactUserRequest(string UserId);
+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);
+49 -3
View File
@@ -51,8 +51,18 @@ public sealed class ChatsController(
Kind = ChatKind.MaxDialog Kind = ChatKind.MaxDialog
}; };
db.Chats.Add(chat); db.Chats.Add(chat);
await db.SaveChangesAsync(cancellationToken);
} }
else
{
chat.Title = request.Title;
chat.AvatarUrl = request.AvatarUrl;
chat.DeletedAt = null;
chat.PendingMaxAction = null;
chat.PendingMaxActionError = null;
chat.UpdatedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
return projection.ToDto(chat); return projection.ToDto(chat);
@@ -274,7 +284,7 @@ public sealed class ChatsController(
} }
[HttpPost("delete")] [HttpPost("delete")]
public IActionResult DeleteChats(ChatBulkActionRequest request) public async Task<IActionResult> DeleteChats(ChatBulkActionRequest request, CancellationToken cancellationToken)
{ {
var chatIds = request.ChatIds.Distinct().ToArray(); var chatIds = request.ChatIds.Distinct().ToArray();
if (chatIds.Length == 0) if (chatIds.Length == 0)
@@ -282,7 +292,43 @@ public sealed class ChatsController(
return BadRequest("chatIds are required."); return BadRequest("chatIds are required.");
} }
return BadRequest("Chat deletion is disabled because MAX does not remove chats from the web client."); var chats = await LoadChatsWithAttachmentsAsync(chatIds, cancellationToken);
if (chats.Count != chatIds.Length || chats.Any(chat => chat.DeletedAt is not null))
{
return NotFound();
}
foreach (var chat in chats)
{
var maxFailure = await TryApplyMaxChatActionAsync(
chat,
(externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken),
"delete chat",
cancellationToken);
if (maxFailure is not null)
{
return maxFailure;
}
}
var now = DateTimeOffset.UtcNow;
var files = AttachmentFiles(chats).ToArray();
foreach (var chat in chats)
{
db.Messages.RemoveRange(chat.Messages);
chat.LastMessageAt = null;
chat.LastMessagePreview = null;
chat.UnreadCount = 0;
chat.DeletedAt = now;
chat.UpdatedAt = now;
chat.PendingMaxAction = null;
chat.PendingMaxActionError = null;
}
await db.SaveChangesAsync(cancellationToken);
DeleteFiles(files);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
return NoContent();
} }
[HttpGet("{chatId:guid}/search")] [HttpGet("{chatId:guid}/search")]
@@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QMax.Api.Contracts;
using QMax.Api.Infrastructure.Max;
namespace QMax.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/contacts")]
public sealed class ContactsController(IMaxBridgeClient maxBridge) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IReadOnlyList<ContactDto>>> GetContacts(CancellationToken cancellationToken)
{
var contacts = await maxBridge.FetchContactsAsync(cancellationToken);
return contacts
.Select(contact => new ContactDto(
contact.UserId,
contact.ExternalChatId,
contact.DisplayName,
contact.AvatarUrl,
contact.PhoneNumber,
contact.Status,
contact.IsSavedContact))
.OrderBy(contact => contact.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
}
[HttpPost("import")]
public async Task<ActionResult<IReadOnlyList<ContactDto>>> ImportContacts(
ImportPhoneContactsRequest request,
CancellationToken cancellationToken)
{
var contacts = request.Contacts
.Where(contact => !string.IsNullOrWhiteSpace(contact.PhoneNumber))
.Take(5000)
.Select(contact => new MaxPhoneContact(
contact.PhoneNumber.Trim(),
string.IsNullOrWhiteSpace(contact.FirstName) ? contact.PhoneNumber.Trim() : contact.FirstName.Trim(),
contact.LastName?.Trim()))
.ToArray();
if (contacts.Length == 0)
{
return BadRequest("contacts with phone numbers are required.");
}
var imported = await maxBridge.ImportContactsAsync(contacts, cancellationToken);
return imported.Select(ToDto).ToArray();
}
[HttpPost("add")]
public async Task<ActionResult<ContactDto>> AddContact(ContactUserRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.UserId))
{
return BadRequest("userId is required.");
}
var contact = await maxBridge.AddContactAsync(request.UserId.Trim(), cancellationToken);
return contact is null ? BadRequest("MAX did not add the contact.") : ToDto(contact);
}
[HttpPost("remove")]
public async Task<IActionResult> RemoveContact(ContactUserRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.UserId))
{
return BadRequest("userId is required.");
}
var result = await maxBridge.RemoveContactAsync(request.UserId.Trim(), cancellationToken);
return result.Success ? NoContent() : BadRequest(result.Error ?? "MAX did not remove the contact.");
}
private static ContactDto ToDto(MaxContact contact) => new(
contact.UserId,
contact.ExternalChatId,
contact.DisplayName,
contact.AvatarUrl,
contact.PhoneNumber,
contact.Status,
contact.IsSavedContact);
}
@@ -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);
}
} }
@@ -7,6 +7,22 @@ public interface IMaxBridgeClient
Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken); Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken);
Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken); Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken); Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<MaxContact>>(Array.Empty<MaxContact>());
}
Task<IReadOnlyList<MaxContact>> ImportContactsAsync(IReadOnlyList<MaxPhoneContact> contacts, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<MaxContact>>(Array.Empty<MaxContact>());
}
Task<MaxContact?> AddContactAsync(string userId, CancellationToken cancellationToken)
{
return Task.FromResult<MaxContact?>(null);
}
Task<MaxActionResult> RemoveContactAsync(string userId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(false, "Contact removal is not supported."));
}
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken); Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken); Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken); Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken);
@@ -19,4 +35,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);
}
} }
@@ -65,3 +65,22 @@ 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);
public sealed record MaxContact(
string UserId,
string ExternalChatId,
string DisplayName,
string? AvatarUrl,
string? PhoneNumber,
string? Status,
bool IsSavedContact = false);
public sealed record MaxPhoneContact(string PhoneNumber, string FirstName, string? LastName);
@@ -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)
]) ])
]; ];
@@ -45,6 +45,28 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates); return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates);
} }
public Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<MaxContact>>([
new MaxContact("mock-user", "mock-direct", "Mock contact", null, "+70000000000", "online", true)
]);
}
public Task<IReadOnlyList<MaxContact>> ImportContactsAsync(IReadOnlyList<MaxPhoneContact> contacts, CancellationToken cancellationToken)
{
return FetchContactsAsync(cancellationToken);
}
public async Task<MaxContact?> AddContactAsync(string userId, CancellationToken cancellationToken)
{
return (await FetchContactsAsync(cancellationToken)).FirstOrDefault();
}
public Task<MaxActionResult> RemoveContactAsync(string userId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId); var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId);
@@ -68,9 +90,7 @@ 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( return Task.FromResult(new MaxActionResult(true, null));
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)
@@ -108,8 +128,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,36 @@ 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<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
{
return await SendAsync<IReadOnlyList<MaxContact>>(HttpMethod.Get, "/contacts", null, cancellationToken)
?? Array.Empty<MaxContact>();
}
public async Task<IReadOnlyList<MaxContact>> ImportContactsAsync(
IReadOnlyList<MaxPhoneContact> contacts,
CancellationToken cancellationToken)
{
return await SendAsync<IReadOnlyList<MaxContact>>(
HttpMethod.Post,
"/contacts/import",
new { contacts },
cancellationToken)
?? Array.Empty<MaxContact>();
}
public async Task<MaxContact?> AddContactAsync(string userId, CancellationToken cancellationToken)
{
return await SendAsync<MaxContact>(HttpMethod.Post, "/contacts/add", new { userId }, cancellationToken);
}
public async Task<MaxActionResult> RemoveContactAsync(string userId, CancellationToken cancellationToken)
{
return await SendAsync<MaxActionResult>(HttpMethod.Post, "/contacts/remove", new { userId }, cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty contact removal result.");
} }
public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
@@ -69,11 +98,14 @@ 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 Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) public async Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult(new MaxActionResult( return await SendAsync<MaxActionResult>(
false, HttpMethod.Post,
"Chat deletion is disabled because MAX does not remove chats from the web client.")); "/chat/delete",
new { externalChatId, chatUrl },
cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty chat deletion 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)
@@ -160,6 +192,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
@@ -0,0 +1,276 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using QMax.Api.Data.Entities;
namespace QMax.Api.Infrastructure.Storage;
public sealed record StoredAttachment(
string OriginalFileName,
string StorageFileName,
string ContentType,
long FileSizeBytes,
string Sha256,
AttachmentKind Kind);
public interface IAttachmentStorageService
{
Task<StoredAttachment> SaveAsync(IFormFile file, CancellationToken cancellationToken);
Task<StoredAttachment> SaveRemoteAsync(
string fileName,
string? contentType,
Stream stream,
long? expectedLength,
AttachmentKind? preferredKind,
CancellationToken cancellationToken);
string GetPath(string storageFileName);
}
public sealed class AttachmentStorageService(IOptions<QMaxOptions> options) : IAttachmentStorageService
{
private readonly QMaxOptions _options = options.Value;
public async Task<StoredAttachment> SaveAsync(IFormFile file, CancellationToken cancellationToken)
{
if (file.Length <= 0)
{
throw new InvalidOperationException("Empty files are not allowed.");
}
if (file.Length > _options.MaxUploadBytes)
{
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
}
Directory.CreateDirectory(_options.StoragePath);
var extension = Path.GetExtension(file.FileName);
if (extension.Length > 16)
{
extension = "";
}
var storageName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}{extension.ToLowerInvariant()}";
var finalPath = GetPath(storageName);
var partPath = finalPath + ".part";
await using (var target = new FileStream(partPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 128 * 1024, true))
{
await file.CopyToAsync(target, cancellationToken);
}
var info = new FileInfo(partPath);
if (info.Length != file.Length)
{
File.Delete(partPath);
throw new IOException("Uploaded file size verification failed.");
}
var hash = await ComputeSha256Async(partPath, cancellationToken);
File.Move(partPath, finalPath, false);
return new StoredAttachment(
Path.GetFileName(file.FileName),
storageName,
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
file.Length,
hash,
GuessKind(file.ContentType, extension));
}
public async Task<StoredAttachment> SaveRemoteAsync(
string fileName,
string? contentType,
Stream stream,
long? expectedLength,
AttachmentKind? preferredKind,
CancellationToken cancellationToken)
{
if (expectedLength is > 0 && expectedLength > _options.MaxUploadBytes)
{
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
}
Directory.CreateDirectory(_options.StoragePath);
var safeOriginalName = NormalizeRemoteFileName(fileName, contentType, preferredKind);
var extension = Path.GetExtension(safeOriginalName);
if (extension.Length > 16)
{
extension = "";
}
var storageName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}{extension.ToLowerInvariant()}";
var finalPath = GetPath(storageName);
var partPath = finalPath + ".part";
await using (var target = new FileStream(partPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 128 * 1024, true))
{
await stream.CopyToAsync(target, cancellationToken);
}
var info = new FileInfo(partPath);
if (info.Length <= 0)
{
File.Delete(partPath);
throw new IOException("Downloaded MAX media is empty.");
}
if (expectedLength is > 0 && info.Length != expectedLength)
{
File.Delete(partPath);
throw new IOException("Downloaded MAX media size verification failed.");
}
if (info.Length > _options.MaxUploadBytes)
{
File.Delete(partPath);
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
}
var resolvedContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
var resolvedKind = preferredKind ?? GuessKind(resolvedContentType, extension);
var hash = await ComputeSha256Async(partPath, cancellationToken);
File.Move(partPath, finalPath, false);
return new StoredAttachment(
safeOriginalName,
storageName,
resolvedContentType,
info.Length,
hash,
resolvedKind);
}
public string GetPath(string storageFileName)
{
var safeName = Path.GetFileName(storageFileName);
return Path.Combine(_options.StoragePath, safeName);
}
private static async Task<string> ComputeSha256Async(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static AttachmentKind GuessKind(string? contentType, string extension)
{
var content = contentType?.ToLowerInvariant() ?? "";
var ext = extension.ToLowerInvariant();
if (content.Contains("vcard", StringComparison.Ordinal) || ext == ".vcf") return AttachmentKind.Contact;
if (content.StartsWith("image/gif") || ext == ".gif") return AttachmentKind.Gif;
if (content.StartsWith("image/")) return AttachmentKind.Image;
if (content.StartsWith("video/")) return AttachmentKind.Video;
if (content.StartsWith("audio/") || ext is ".ogg" or ".opus" or ".m4a" or ".aac" or ".mp3" or ".wav" or ".flac") return AttachmentKind.VoiceNote;
return AttachmentKind.File;
}
public static string NormalizeRemoteFileName(string? fileName, string? contentType, AttachmentKind? preferredKind)
{
var original = string.IsNullOrWhiteSpace(fileName) ? "" : Path.GetFileName(fileName.Trim());
var extension = Path.GetExtension(original);
if (extension.Length > 16)
{
extension = "";
}
var resolvedContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
var kind = preferredKind ?? GuessKind(resolvedContentType, extension);
var fallbackExtension = string.IsNullOrWhiteSpace(extension)
? DefaultExtension(resolvedContentType, kind)
: extension.ToLowerInvariant();
return IsUnhelpfulRemoteFileName(original, kind, extension)
? $"max-{KindSlug(kind)}{fallbackExtension}"
: original;
}
private static bool IsUnhelpfulRemoteFileName(string value, AttachmentKind kind, string extension)
{
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
var normalized = value.Trim().ToLowerInvariant();
var stem = Path.GetFileNameWithoutExtension(normalized).Trim();
if (stem is
"photo" or
"image" or
"video" or
"audio" or
"voice" or
"gif" or
"media" or
"contact" or
"\u0444\u043e\u0442\u043e" or
"\u0432\u0438\u0434\u0435\u043e" or
"\u0430\u0443\u0434\u0438\u043e" or
"\u0433\u043e\u043b\u043e\u0441" or
"\u043a\u043e\u043d\u0442\u0430\u043a\u0442")
{
return true;
}
if (normalized.Contains("\u0431\u0440\u0430\u0443\u0437\u0435\u0440", StringComparison.Ordinal) &&
normalized.Contains("\u043d\u0435", StringComparison.Ordinal) &&
normalized.Contains("\u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430", StringComparison.Ordinal))
{
return true;
}
if (normalized.Contains("your browser", StringComparison.Ordinal) ||
normalized.Contains("not supported", StringComparison.Ordinal) ||
normalized.Contains("[object object]", StringComparison.Ordinal))
{
return true;
}
return string.IsNullOrWhiteSpace(extension) &&
kind is AttachmentKind.Image or AttachmentKind.Gif or AttachmentKind.Video or AttachmentKind.VoiceNote or AttachmentKind.Sticker;
}
private static string DefaultExtension(string contentType, AttachmentKind kind)
{
var content = contentType.ToLowerInvariant();
if (content.Contains("webp", StringComparison.Ordinal)) return ".webp";
if (content.Contains("jpeg", StringComparison.Ordinal) || content.Contains("jpg", StringComparison.Ordinal)) return ".jpg";
if (content.Contains("png", StringComparison.Ordinal)) return ".png";
if (content.Contains("gif", StringComparison.Ordinal)) return ".gif";
if (content.Contains("mp4", StringComparison.Ordinal)) return kind == AttachmentKind.VoiceNote ? ".m4a" : ".mp4";
if (content.Contains("webm", StringComparison.Ordinal)) return ".webm";
if (content.Contains("ogg", StringComparison.Ordinal) || content.Contains("opus", StringComparison.Ordinal)) return ".ogg";
if (content.Contains("mpeg", StringComparison.Ordinal)) return ".mp3";
if (content.Contains("wav", StringComparison.Ordinal)) return ".wav";
if (content.Contains("pdf", StringComparison.Ordinal)) return ".pdf";
if (content.Contains("vcard", StringComparison.Ordinal)) return ".vcf";
return kind switch
{
AttachmentKind.Image => ".jpg",
AttachmentKind.Gif => ".gif",
AttachmentKind.Video => ".mp4",
AttachmentKind.VoiceNote => ".ogg",
AttachmentKind.Sticker => ".webp",
AttachmentKind.Contact => ".vcf",
_ => ""
};
}
private static string KindSlug(AttachmentKind kind)
{
return kind switch
{
AttachmentKind.Image => "image",
AttachmentKind.Gif => "gif",
AttachmentKind.Video => "video",
AttachmentKind.VoiceNote => "voice",
AttachmentKind.Sticker => "sticker",
AttachmentKind.Contact => "contact",
AttachmentKind.File => "file",
_ => "media"
};
}
}
@@ -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))
@@ -59,6 +93,16 @@ 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,
@@ -1387,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 -3
View File
@@ -125,9 +125,10 @@ public sealed class MaxOutboxService(
chat, chat,
(externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken), (externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken),
cancellationToken), cancellationToken),
ChatPendingMaxAction.DeleteChat => new MaxActionResult( ChatPendingMaxAction.DeleteChat => await ApplyMaxChatActionAsync(
false, chat,
"Chat deletion is disabled because MAX does not remove chats from the web client."), (externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken),
cancellationToken),
_ => new MaxActionResult(false, $"Unsupported chat action: {pendingAction.Value}.") _ => new MaxActionResult(false, $"Unsupported chat action: {pendingAction.Value}.")
}; };
} }
+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,
+43 -3
View File
@@ -112,7 +112,44 @@ 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]
public async Task ContactCanCreateDirectChat()
{
using var client = _factory.CreateClient();
await LoginAsync(client);
var contacts = await client.GetFromJsonAsync<ContactDto[]>("/api/contacts", JsonOptions);
var contact = Assert.Single(contacts!);
Assert.Equal("mock-direct", contact.ExternalChatId);
Assert.Equal("Mock contact", contact.DisplayName);
Assert.True(contact.IsSavedContact);
var importedResponse = await client.PostAsJsonAsync(
"/api/contacts/import",
new ImportPhoneContactsRequest([new PhoneContactDto("+70000000000", "Mock contact")]));
await AssertStatusAsync(HttpStatusCode.OK, importedResponse);
var imported = await importedResponse.Content.ReadFromJsonAsync<ContactDto[]>(JsonOptions);
Assert.Single(imported!);
var response = await client.PostAsJsonAsync(
"/api/chats/direct",
new CreateDirectChatRequest(contact.ExternalChatId, contact.DisplayName, contact.AvatarUrl));
await AssertStatusAsync(HttpStatusCode.OK, response);
var chat = await response.Content.ReadFromJsonAsync<ChatDto>(JsonOptions);
Assert.NotNull(chat);
Assert.Equal(contact.ExternalChatId, chat!.ExternalId);
Assert.Equal(contact.DisplayName, chat.Title);
var removeContact = await client.PostAsJsonAsync("/api/contacts/remove", new ContactUserRequest(contact.UserId));
await AssertStatusAsync(HttpStatusCode.NoContent, removeContact);
var deleteChat = await client.PostAsJsonAsync("/api/chats/delete", new ChatBulkActionRequest([chat.Id]));
await AssertStatusAsync(HttpStatusCode.NoContent, deleteChat);
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.DoesNotContain(chats!, candidate => candidate.Id == chat.Id);
} }
[Fact] [Fact]
@@ -1104,8 +1141,11 @@ public sealed class ApiSmokeTests : IDisposable
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", "clear"], 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]
-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"
}
}
-7052
View File
File diff suppressed because it is too large Load Diff