Compare commits

..
20 Commits
Author SHA1 Message Date
Курнат Андрей 46bbcbd313 Update Argus Pi publication path 2026-08-08 14:44:51 +03:00
Курнат Андрей 5845584933 Show senders in MAX group chats 2026-08-08 14:43:14 +03:00
Курнат Андрей 43a857afb5 Add image viewer gallery actions 2026-07-21 16:01:10 +03:00
Курнат Андрей e59ca21c2a Handle incoming MAX call events 2026-07-21 15:53:32 +03:00
Курнат Андрей 27f9743c71 Improve Android chat navigation and sharing 2026-07-17 17:04:28 +03:00
Курнат Андрей 51437c81a5 Release QMAX 1.0.1 with Telegram-style theme editor 2026-07-16 00:07:23 +03:00
Курнат Андрей 0febe58b69 Release QMAX 1.0.0 with customizable themes 2026-07-15 12:22:55 +03:00
Курнат Андрей 8e9a4c1151 Fix voice message forwarding and playback 2026-07-14 14:53:18 +03:00
Курнат Андрей edf108f621 Ignore generated Python bytecode 2026-07-14 07:35:27 +03:00
Курнат Андрей 440de7325f Add multi-user MAX authentication and tenant isolation 2026-07-14 07:35:04 +03:00
Курнат Андрей 582f99ed0e Sync phone contacts and enable chat deletion 2026-07-13 21:10:10 +03:00
Курнат Андрей ff3f139889 Add MAX contacts and direct chat creation 2026-07-12 22:51:24 +03:00
sevenhill 51d4c7dd1d Enable forwarding from all attachment types 2026-07-09 18:02:42 +03:00
sevenhill c5330f3086 Improve image preview navigation 2026-07-09 15:38:43 +03:00
sevenhill 82f241df13 Improve message forwarding picker 2026-07-09 15:21:02 +03:00
sevenhill 0a42e6f860 Add channel search and subscription 2026-07-07 22:09:18 +03:00
sevenhill b2766ebbe9 Open chats at latest message 2026-07-07 14:35:26 +03:00
sevenhill af84343e19 Switch MAX bridge to PyMax only 2026-07-07 14:18:49 +03:00
sevenhill 8efeff334a Switch server bridge to PyMax 2026-07-07 00:05:47 +03:00
sevenhill 81b20dcaec Add PyMax worker POC 2026-07-06 23:32:22 +03:00
70 changed files with 7756 additions and 8520 deletions
+4
View File
@@ -6,6 +6,8 @@ obj/
build/
artifacts/
node_modules/
__pycache__/
*.py[cod]
local.properties
*.user
*.suo
@@ -23,6 +25,8 @@ data/
!server/QMax.Api/Data/
!server/QMax.Api/Data/**
storage/
!server/QMax.Api/Infrastructure/Storage/
!server/QMax.Api/Infrastructure/Storage/**
secrets/
.env
.env.*
+5 -5
View File
@@ -10,9 +10,9 @@ Argus в текущей архитектуре является read-only web ca
- SSH host: `192.168.0.185`
- SSH user: `sevenhill`
- Server project path: `/home/sevenhill/argus`
- Data path: `/srv/argus-data`
- SQLite database: `/srv/argus-data/argus.db`
- Package root: `/srv/argus-data/Packages`
- Data path: `/mnt/data/argus`
- SQLite database: `/mnt/data/argus/argus.db`
- Package root: `/mnt/data/argus/Packages`
Do not put SSH passwords, private keys, or local `.env` files into application repositories.
@@ -50,7 +50,7 @@ Run this publication block on the Pi after editing the variables at the top:
```bash
set -euo pipefail
export ARGUS_DATA="/srv/argus-data"
export ARGUS_DATA="/mnt/data/argus"
export SOURCE_FILE="/tmp/my-app-1.2.3.zip"
export ARGUS_SLUG="my-app"
@@ -80,7 +80,7 @@ from datetime import datetime, timezone
from pathlib import Path
source = Path(sys.argv[1]).resolve()
data_root = Path(os.environ.get("ARGUS_DATA", "/srv/argus-data")).resolve()
data_root = Path(os.environ.get("ARGUS_DATA", "/mnt/data/argus")).resolve()
db_path = data_root / "argus.db"
packages_root = data_root / "Packages"
+17 -25
View File
@@ -1,11 +1,11 @@
# 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 multi-user Android messenger client backed by a self-hosted bridge server. Each Android user verifies a separate MAX account, while one QMAX server and one PyMax worker can serve all accounts.
Current shape:
- `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 an isolated persistent MAX mobile API session per QMAX user.
- `android` - Kotlin + Jetpack Compose Android client pointed at `https://qmax.kusoft.xyz`.
- `deploy` - Docker Compose + Caddy for Raspberry Pi 5.
@@ -13,11 +13,9 @@ Current shape:
```powershell
dotnet test QMax.slnx
python -m py_compile pymax-worker/src/server.py
cd android
.\gradlew.bat :app:assembleDebug :app:assembleRelease --console=plain --no-daemon
cd ..\worker
npm.cmd install
node --check src/server.js
```
## Raspberry Pi Deployment
@@ -46,22 +44,15 @@ qmax.kusoft.xyz {
Set strong values in `.env`:
- `QMAX_JWT_SECRET` - at least 32 random characters.
- `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_PAIRING_CODE` - optional server registration code shared with allowed users; leave it empty for open registration.
Secrets must stay in `.env`, not in git.
## MAX Login
Open:
On the Android login screen enter the QMAX server URL, the user's MAX phone number and, when configured, `QMAX_PAIRING_CODE`. QMAX asks PyMax to start MAX authorization. Enter the code delivered by MAX in the app; only after PyMax reports an authorized session does QMAX issue that user a JWT and refresh token.
```text
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.
The worker stores browser state in the `qmax-max-profile` Docker volume, so the MAX session should survive restarts.
The worker stores sessions under `accounts/<QMAX user id>/` in the `qmax-pymax-session` Docker volume. Session files and imported-contact mappings are not shared between users.
## Android Pairing
@@ -75,7 +66,9 @@ cd android
On first launch:
- Server: `https://qmax.kusoft.xyz`
- Code: `QMAX_PAIRING_CODE` from `.env`
- Phone: the user's MAX phone number
- Registration code: `QMAX_PAIRING_CODE` from `.env`, if the server owner configured one
- MAX code: the confirmation code sent by MAX after the first step
The first screen after pairing is the chat list.
@@ -130,16 +123,15 @@ https://argus.kusoft.xyz/api/apps/qmax/download/latest?platform=android&channel=
The Android app checks this manifest, compares semantic versions, downloads the APK to a temporary file, verifies SHA-256, and only then opens Android's package installer.
## Current MAX Mapping Status
## Implemented MAX Mapping
The deployed worker is authorized in MAX Web and currently maps:
The worker code maps:
- chat list extraction from the left MAX Web dialog list;
- visible chat history extraction when Android opens a dialog;
- text sending through the MAX Web composer;
- attachment upload through the MAX Web file chooser;
- image, video, file and voice attachment projection from MAX Web;
- chat list and message history through PyMax;
- text sending through PyMax;
- attachment upload through PyMax file/photo/video models;
- image, video, file and voice attachment projection through the API;
- Android image attachment caching with `.part` downloads before a file is shown from local storage;
- manual browser inspection endpoints for future selector changes.
- explicit per-user 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.
An end-to-end login against the live MAX service was not run in this workspace; it must be verified on the target Raspberry Pi with real accounts.
+2 -2
View File
@@ -22,8 +22,8 @@ android {
applicationId = "xyz.kusoft.qmax"
minSdk = 26
targetSdk = 36
versionCode = 51
versionName = "0.1.50"
versionCode = 60
versionName = "1.0.3"
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
+12
View File
@@ -2,8 +2,10 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<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_VIDEO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
@@ -49,6 +51,16 @@
<action android:name="QMAX_OPEN_CHAT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
</application>
</manifest>
File diff suppressed because it is too large Load Diff
@@ -5,10 +5,12 @@ import xyz.kusoft.qmax.core.local.AvatarDiskCache
import xyz.kusoft.qmax.core.local.AttachmentDiskCache
import xyz.kusoft.qmax.core.local.LocalMessageCache
import xyz.kusoft.qmax.core.network.QMaxApi
import xyz.kusoft.qmax.core.settings.AppearanceStore
import xyz.kusoft.qmax.core.settings.MessageDraftStore
import xyz.kusoft.qmax.core.settings.TokenStore
class QMaxContainer(context: Context) {
val appearanceStore = AppearanceStore(context)
val tokenStore = TokenStore(context)
val draftStore = MessageDraftStore(context)
val messageCache = LocalMessageCache(context)
@@ -4,7 +4,12 @@ import android.content.Context
import android.content.Intent
import android.content.ClipData
import android.content.ActivityNotFoundException
import android.content.ContentValues
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.util.Log
import androidx.core.content.FileProvider
@@ -26,7 +31,11 @@ import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.AuthResponse
import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ChatPresenceDto
import xyz.kusoft.qmax.core.model.ContactDto
import xyz.kusoft.qmax.core.model.PhoneContactDto
import xyz.kusoft.qmax.core.model.PhoneAuthChallengeResponse
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.QMaxSession
import xyz.kusoft.qmax.core.network.QMaxApi
@@ -65,6 +74,18 @@ class QMaxRepository(
return response
}
suspend fun beginPhoneAuth(serverUrl: String, phoneNumber: String, registrationCode: String): PhoneAuthChallengeResponse {
val resolvedServer = serverUrl.ifBlank { BuildConfig.QMAX_DEFAULT_SERVER_URL }
return api.beginPhoneAuth(resolvedServer, phoneNumber, registrationCode.ifBlank { null }, android.os.Build.MODEL)
}
suspend fun completePhoneAuth(serverUrl: String, challengeId: String, challengeToken: String, code: String): AuthResponse {
val resolvedServer = serverUrl.ifBlank { BuildConfig.QMAX_DEFAULT_SERVER_URL }
val response = api.completePhoneAuth(resolvedServer, challengeId, challengeToken, code)
tokenStore.save(QMaxSession(resolvedServer, response.accessToken, response.refreshToken, response.user.displayName))
return response
}
suspend fun logout() {
tokenStore.clear()
draftStore.clearAll()
@@ -90,8 +111,26 @@ class QMaxRepository(
return chats
}
suspend fun createChat(session: QMaxSession, externalId: String, title: String): ChatDto {
val chat = withFreshSession(session) { api.createDirect(it.serverUrl, it.accessToken, externalId, title) }
suspend fun contacts(session: QMaxSession): List<ContactDto> {
return withFreshSession(session) { api.contacts(it.serverUrl, it.accessToken) }
}
suspend fun 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)
.let(::orderedChats)
messageCache.saveChats(session, updatedChats)
@@ -136,6 +175,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> {
val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) }
messages.forEach { messageCache.upsertMessage(session, it) }
@@ -297,6 +345,51 @@ class QMaxRepository(
sharePayload(session = session, text = null, attachments = listOf(attachment))
}
suspend fun saveImageToGallery(session: QMaxSession, attachment: AttachmentDto): Uri {
val source = cachedAttachmentFile(session, attachment)
val mimeType = attachment.contentType.substringBefore(';').trim().takeIf { it.startsWith("image/") }
?: "image/jpeg"
val displayName = File(attachment.fileName.substringAfterLast('/')).name.trim().ifBlank {
"qmax-${System.currentTimeMillis()}.${mimeType.substringAfter('/', "jpg")}"
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
val directory = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
"Qmax"
)
if (!directory.exists() && !directory.mkdirs()) {
error("Не удалось создать папку Pictures/Qmax")
}
val target = uniqueGalleryFile(directory, displayName)
source.copyTo(target, overwrite = false)
MediaScannerConnection.scanFile(context, arrayOf(target.absolutePath), arrayOf(mimeType), null)
return Uri.fromFile(target)
}
val resolver = context.contentResolver
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
put(MediaStore.Images.Media.MIME_TYPE, mimeType)
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Qmax")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: error("Не удалось создать изображение в галерее")
try {
resolver.openOutputStream(uri, "w")?.use { output ->
source.inputStream().use { input -> input.copyTo(output) }
} ?: error("Не удалось записать изображение в галерею")
resolver.update(uri, ContentValues().apply {
put(MediaStore.Images.Media.IS_PENDING, 0)
}, null, null)
return uri
} catch (error: Throwable) {
resolver.delete(uri, null, null)
throw error
}
}
suspend fun maxStatus(session: QMaxSession): MaxBridgeStatusDto {
return withFreshSession(session) { api.maxStatus(it.serverUrl, it.accessToken) }
}
@@ -309,6 +402,18 @@ class QMaxRepository(
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 {
if (!FirebaseBootstrap.ensureInitialized(context)) {
Log.w(PushLogTag, "Firebase is not initialized; push device registration skipped")
@@ -385,6 +490,21 @@ class QMaxRepository(
}
}
private fun uniqueGalleryFile(directory: File, displayName: String): File {
val initial = directory.resolve(displayName)
if (!initial.exists()) return initial
val extension = displayName.substringAfterLast('.', missingDelimiterValue = "")
val baseName = if (extension.isBlank()) displayName else displayName.removeSuffix(".$extension")
var suffix = 2
while (true) {
val candidateName = if (extension.isBlank()) "$baseName ($suffix)" else "$baseName ($suffix).$extension"
val candidate = directory.resolve(candidateName)
if (!candidate.exists()) return candidate
suffix += 1
}
}
private fun commonShareMime(types: List<String>): String {
val normalized = types.map { it.takeIf(String::isNotBlank) ?: "application/octet-stream" }
if (normalized.isEmpty()) return "*/*"
@@ -124,12 +124,18 @@ class LocalMessageCache(context: Context) {
private fun cleanChat(chat: ChatDto): ChatDto {
return when {
isCallMediaLabel(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a")
isGenericMediaLabel(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u041c\u0435\u0434\u0438\u0430")
isQuestionMarkArtifact(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435")
else -> chat
}
}
private fun isCallMediaLabel(value: String?): Boolean {
val text = value?.trim().orEmpty()
return text.equals("call", ignoreCase = true) || text.startsWith("call-", ignoreCase = true)
}
private fun isGenericMediaPlaceholder(message: MessageDto): Boolean {
return message.attachments.isEmpty() && isGenericMediaLabel(message.text)
}
@@ -24,6 +24,20 @@ data class DeviceLoginRequest(
val deviceName: String
)
@Serializable
data class BeginPhoneAuthRequest(val phoneNumber: String, val deviceName: String, val registrationCode: String? = null)
@Serializable
data class CompletePhoneAuthRequest(val challengeId: String, val challengeToken: String, val code: String)
@Serializable
data class PhoneAuthChallengeResponse(
val challengeId: String,
val challengeToken: String,
val maxStatus: MaxBridgeStatusDto,
val expiresAt: String
)
@Serializable
data class RefreshTokenRequest(val refreshToken: String)
@@ -49,6 +63,30 @@ data class ChatPresenceDto(
val updatedAt: String? = null
)
@Serializable
data class ContactDto(
val userId: String,
val externalChatId: String,
val displayName: String,
val avatarUrl: String? = null,
val phoneNumber: String? = null,
val status: String? = null,
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
data class MessageDto(
val id: String,
@@ -148,6 +186,19 @@ data class BeginMaxLoginRequest(val phoneNumber: String? = null)
@Serializable
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
data class RegisterPushDeviceRequest(
val firebaseToken: String,
@@ -22,19 +22,28 @@ import xyz.kusoft.qmax.BuildConfig
import xyz.kusoft.qmax.core.model.AuthResponse
import xyz.kusoft.qmax.core.model.ArgusManifestDto
import xyz.kusoft.qmax.core.model.BeginMaxLoginRequest
import xyz.kusoft.qmax.core.model.BeginPhoneAuthRequest
import xyz.kusoft.qmax.core.model.ChatBulkActionRequest
import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ChatPresenceDto
import xyz.kusoft.qmax.core.model.CreateDirectChatRequest
import xyz.kusoft.qmax.core.model.ContactDto
import xyz.kusoft.qmax.core.model.ContactUserRequest
import xyz.kusoft.qmax.core.model.DeviceLoginRequest
import xyz.kusoft.qmax.core.model.CompletePhoneAuthRequest
import xyz.kusoft.qmax.core.model.PhoneAuthChallengeResponse
import xyz.kusoft.qmax.core.model.EditMessageRequest
import xyz.kusoft.qmax.core.model.ForwardMessageRequest
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.MaxChannelSearchResultDto
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.SendMessageRequest
import xyz.kusoft.qmax.core.model.SetReactionRequest
import xyz.kusoft.qmax.core.model.SubscribeMaxChannelRequest
import xyz.kusoft.qmax.core.model.SubmitMaxCodeRequest
import java.io.IOException
import java.io.File
@@ -67,6 +76,14 @@ class QMaxApi {
return post(serverUrl, "/api/auth/device/login", null, DeviceLoginRequest(pairingCode, deviceName))
}
suspend fun beginPhoneAuth(serverUrl: String, phoneNumber: String, registrationCode: String?, deviceName: String): PhoneAuthChallengeResponse {
return post(serverUrl, "/api/auth/phone/start", null, BeginPhoneAuthRequest(phoneNumber, deviceName, registrationCode))
}
suspend fun completePhoneAuth(serverUrl: String, challengeId: String, challengeToken: String, code: String): AuthResponse {
return post(serverUrl, "/api/auth/phone/code", null, CompletePhoneAuthRequest(challengeId, challengeToken, code))
}
suspend fun refresh(serverUrl: String, refreshToken: String): AuthResponse {
return post(serverUrl, "/api/auth/refresh", null, xyz.kusoft.qmax.core.model.RefreshTokenRequest(refreshToken))
}
@@ -75,8 +92,30 @@ class QMaxApi {
return get(sessionServerUrl, "/api/chats", token)
}
suspend fun createDirect(sessionServerUrl: String, token: String, externalChatId: String, title: String): ChatDto {
return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title))
suspend fun contacts(sessionServerUrl: String, token: String): List<ContactDto> {
return get(sessionServerUrl, "/api/contacts", token)
}
suspend fun 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> {
@@ -91,6 +130,10 @@ class QMaxApi {
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> {
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token)
@@ -187,6 +230,15 @@ class QMaxApi {
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) {
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.withContext
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.MessageDto
import xyz.kusoft.qmax.core.model.QMaxSession
@@ -26,7 +27,8 @@ class QMaxRealtimeClient {
onChatListInvalidated: () -> Unit,
onMessageCreated: (MessageDto) -> Unit,
onMessageUpdated: (MessageDto) -> Unit,
onMessageDeleted: (MessageDeletedDto) -> Unit
onMessageDeleted: (MessageDeletedDto) -> Unit,
onMaxStatusChanged: (MaxBridgeStatusDto) -> Unit
) = withContext(Dispatchers.IO) {
disconnect()
@@ -36,6 +38,15 @@ class QMaxRealtimeClient {
.build()
connection.on("ChatListInvalidated", onChatListInvalidated)
connection.on(
"MaxStatusChanged",
{ payload: JsonElement ->
runCatching {
onMaxStatusChanged(json.decodeFromString<MaxBridgeStatusDto>(payload.toString()))
}
},
JsonElement::class.java
)
connection.on(
"MessageCreated",
{ payload: JsonElement ->
@@ -0,0 +1,89 @@
package xyz.kusoft.qmax.core.settings
enum class ThemeMode {
System,
Light,
Dark
}
data class ChatPalette(
val accent: Int,
val chatBackground: Int,
val incomingBubble: Int,
val outgoingBubble: Int,
val messageText: Int,
val patternColor: Int,
val showPattern: Boolean = true,
val outgoingGradientColors: List<Int> = emptyList(),
val animateOutgoingGradient: Boolean = false
)
object ChatPalettes {
val Light = ChatPalette(
accent = 0xFF3390EC.toInt(),
chatBackground = 0xFFE7EEF3.toInt(),
incomingBubble = 0xFFFFFFFF.toInt(),
outgoingBubble = 0xFFE7F7C8.toInt(),
messageText = 0xFF17212B.toInt(),
patternColor = 0xFF6C7883.toInt()
)
val Dark = ChatPalette(
accent = 0xFF5AA7E8.toInt(),
chatBackground = 0xFF0E1621.toInt(),
incomingBubble = 0xFF182533.toInt(),
outgoingBubble = 0xFF2B5278.toInt(),
messageText = 0xFFF5F7FA.toInt(),
patternColor = 0xFF6E7F91.toInt()
)
val OceanLight = Light.copy(
accent = 0xFF168ACD.toInt(),
chatBackground = 0xFFDCEEF6.toInt(),
incomingBubble = 0xFFF8FCFE.toInt(),
outgoingBubble = 0xFFCDEAF7.toInt(),
patternColor = 0xFF5192AE.toInt()
)
val MintLight = Light.copy(
accent = 0xFF20A98A.toInt(),
chatBackground = 0xFFDDEFE8.toInt(),
incomingBubble = 0xFFFFFFFF.toInt(),
outgoingBubble = 0xFFCFEFDF.toInt(),
patternColor = 0xFF4C8C78.toInt()
)
val MidnightDark = Dark.copy(
accent = 0xFF8774E1.toInt(),
chatBackground = 0xFF10131A.toInt(),
incomingBubble = 0xFF20232D.toInt(),
outgoingBubble = 0xFF5B4C95.toInt(),
patternColor = 0xFF666B85.toInt()
)
val ForestDark = Dark.copy(
accent = 0xFF4DBB8B.toInt(),
chatBackground = 0xFF101A17.toInt(),
incomingBubble = 0xFF1B2924.toInt(),
outgoingBubble = 0xFF285C4A.toInt(),
patternColor = 0xFF55796D.toInt()
)
}
data class AppearanceSettings(
val mode: ThemeMode = ThemeMode.System,
val lightPalette: ChatPalette = ChatPalettes.Light,
val darkPalette: ChatPalette = ChatPalettes.Dark,
val chatFontSizeSp: Float = 16f,
val chatLineSpacingSp: Float = 6f
) {
fun palette(isDark: Boolean): ChatPalette = if (isDark) darkPalette else lightPalette
fun withPalette(isDark: Boolean, palette: ChatPalette): AppearanceSettings {
return if (isDark) copy(darkPalette = palette) else copy(lightPalette = palette)
}
fun resetPalette(isDark: Boolean): AppearanceSettings {
return withPalette(isDark, if (isDark) ChatPalettes.Dark else ChatPalettes.Light)
}
}
@@ -0,0 +1,93 @@
package xyz.kusoft.qmax.core.settings
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.qmaxAppearanceDataStore by preferencesDataStore("qmax_appearance")
class AppearanceStore(private val context: Context) {
private val modeKey = stringPreferencesKey("theme_mode")
private val chatFontSizeKey = floatPreferencesKey("chat_font_size_sp")
private val chatLineSpacingKey = floatPreferencesKey("chat_line_spacing_sp")
private val lightKeys = PaletteKeys("light")
private val darkKeys = PaletteKeys("dark")
val settings: Flow<AppearanceSettings> = context.qmaxAppearanceDataStore.data.map { preferences ->
val mode = preferences[modeKey]
?.let { saved -> ThemeMode.entries.firstOrNull { it.name == saved } }
?: ThemeMode.System
AppearanceSettings(
mode = mode,
lightPalette = preferences.readPalette(lightKeys, ChatPalettes.Light),
darkPalette = preferences.readPalette(darkKeys, ChatPalettes.Dark),
chatFontSizeSp = (preferences[chatFontSizeKey] ?: 16f).coerceIn(12f, 24f),
chatLineSpacingSp = (preferences[chatLineSpacingKey] ?: 6f).coerceIn(0f, 12f)
)
}
suspend fun save(settings: AppearanceSettings) {
context.qmaxAppearanceDataStore.edit { preferences ->
preferences[modeKey] = settings.mode.name
preferences.writePalette(lightKeys, settings.lightPalette)
preferences.writePalette(darkKeys, settings.darkPalette)
preferences[chatFontSizeKey] = settings.chatFontSizeSp.coerceIn(12f, 24f)
preferences[chatLineSpacingKey] = settings.chatLineSpacingSp.coerceIn(0f, 12f)
}
}
private data class PaletteKeys(val prefix: String) {
val accent = intPreferencesKey("${prefix}_accent")
val chatBackground = intPreferencesKey("${prefix}_chat_background")
val incomingBubble = intPreferencesKey("${prefix}_incoming_bubble")
val outgoingBubble = intPreferencesKey("${prefix}_outgoing_bubble")
val messageText = intPreferencesKey("${prefix}_message_text")
val patternColor = intPreferencesKey("${prefix}_pattern_color")
val showPattern = booleanPreferencesKey("${prefix}_show_pattern")
val outgoingGradientColors = stringPreferencesKey("${prefix}_outgoing_gradient_colors")
val animateOutgoingGradient = booleanPreferencesKey("${prefix}_animate_outgoing_gradient")
}
private fun Preferences.readPalette(keys: PaletteKeys, fallback: ChatPalette): ChatPalette {
return ChatPalette(
accent = this[keys.accent] ?: fallback.accent,
chatBackground = this[keys.chatBackground] ?: fallback.chatBackground,
incomingBubble = this[keys.incomingBubble] ?: fallback.incomingBubble,
outgoingBubble = this[keys.outgoingBubble] ?: fallback.outgoingBubble,
messageText = this[keys.messageText] ?: fallback.messageText,
patternColor = this[keys.patternColor] ?: fallback.patternColor,
showPattern = this[keys.showPattern] ?: fallback.showPattern,
outgoingGradientColors = this[keys.outgoingGradientColors]
?.split(',')
?.mapNotNull { value -> value.toLongOrNull(16)?.toInt() }
?.take(4)
?: fallback.outgoingGradientColors,
animateOutgoingGradient = this[keys.animateOutgoingGradient] ?: fallback.animateOutgoingGradient
)
}
private fun androidx.datastore.preferences.core.MutablePreferences.writePalette(
keys: PaletteKeys,
palette: ChatPalette
) {
this[keys.accent] = palette.accent
this[keys.chatBackground] = palette.chatBackground
this[keys.incomingBubble] = palette.incomingBubble
this[keys.outgoingBubble] = palette.outgoingBubble
this[keys.messageText] = palette.messageText
this[keys.patternColor] = palette.patternColor
this[keys.showPattern] = palette.showPattern
this[keys.outgoingGradientColors] = palette.outgoingGradientColors
.take(4)
.joinToString(",") { color -> color.toUInt().toString(16) }
this[keys.animateOutgoingGradient] = palette.animateOutgoingGradient
}
}
@@ -16,9 +16,13 @@ import xyz.kusoft.qmax.BuildConfig
import xyz.kusoft.qmax.core.QMaxRepository
import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ContactDto
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
import xyz.kusoft.qmax.core.model.MessageDeletedDto
import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.PhoneContactDto
import xyz.kusoft.qmax.core.model.PhoneAuthChallengeResponse
import xyz.kusoft.qmax.core.model.QMaxSession
import xyz.kusoft.qmax.core.network.QMaxHttpException
import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient
@@ -29,12 +33,23 @@ import java.util.UUID
data class QMaxUiState(
val serverUrl: String = BuildConfig.QMAX_DEFAULT_SERVER_URL,
val pairingCode: String = BuildConfig.QMAX_DEFAULT_PAIRING_CODE,
val phoneNumber: String = "",
val phoneAuthChallenge: PhoneAuthChallengeResponse? = null,
val session: QMaxSession? = null,
val chats: List<ChatDto> = emptyList(),
val contacts: List<ContactDto> = emptyList(),
val contactsLoading: Boolean = false,
val contactsError: String? = null,
val selectedChat: ChatDto? = null,
val initialUnreadCount: Int = 0,
val initialUnreadThrough: String? = null,
val initialMessageLoadComplete: Boolean = false,
val messages: List<MessageDto> = emptyList(),
val chatSearchResults: List<MessageDto> = emptyList(),
val chatSearchLoading: Boolean = false,
val channelSearchResults: List<MaxChannelSearchResultDto> = emptyList(),
val channelSearchLoading: Boolean = false,
val channelSearchError: String? = null,
val chatPresenceText: String? = null,
val replyTarget: MessageDto? = null,
val editTarget: MessageDto? = null,
@@ -72,6 +87,8 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
private var chatRefreshJob: Job? = null
private var chatRefreshGeneration = 0L
private var chatSearchJob: Job? = null
private var channelSearchJob: Job? = null
private var maxStatusPollJob: Job? = null
private var autoLoginJob: Job? = null
private var pushRegisteredForToken: String? = null
private var pushRegistrationJob: Job? = null
@@ -99,24 +116,30 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
serverUrl = session?.serverUrl ?: state.value.serverUrl,
cachedImagePaths = if (sessionChanged) emptyMap() else state.value.cachedImagePaths,
cachedAvatarPaths = if (sessionChanged) emptyMap() else state.value.cachedAvatarPaths,
contacts = if (sessionChanged) emptyList() else state.value.contacts,
contactsLoading = if (sessionChanged) false else state.value.contactsLoading,
contactsError = if (sessionChanged) null else state.value.contactsError,
selectedChatIds = if (sessionChanged) emptySet() else state.value.selectedChatIds
)
if (session != null) {
autoLoginJob?.cancel()
restoreCachedChats(session)
loadChats()
loadContacts()
connectRealtime(session)
registerPushDevice(session)
loadMaxStatus()
startMaxStatusPolling(session)
} else {
realtimeConnectJob?.cancel()
messagePollJob?.cancel()
presencePollJob?.cancel()
maxStatusPollJob?.cancel()
channelSearchJob?.cancel()
pushRegistrationJob?.cancel()
pushRegistrationInFlightForToken = null
pushRegisteredForToken = null
realtime.disconnect()
if (!autoLoginAttempted && state.value.pairingCode.isNotBlank()) {
if (!autoLoginAttempted && state.value.phoneNumber.isNotBlank()) {
autoLoginAttempted = true
autoLoginJob?.cancel()
autoLoginJob = viewModelScope.launch {
@@ -145,6 +168,10 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
state.value = state.value.copy(pairingCode = value)
}
fun updatePhoneNumber(value: String) {
state.value = state.value.copy(phoneNumber = value)
}
fun updateComposer(value: String) {
state.value = state.value.copy(composerText = value)
if (state.value.editTarget != null) {
@@ -160,6 +187,61 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
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) {
if (chatId.isBlank()) return
state.value = state.value.copy(selectedChatIds = state.value.selectedChatIds + chatId)
@@ -191,7 +273,17 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
fun login() = launchLoading {
val current = state.value
repository.login(current.serverUrl, current.pairingCode)
val challenge = repository.beginPhoneAuth(current.serverUrl, current.phoneNumber, current.pairingCode)
state.value = state.value.copy(phoneAuthChallenge = challenge, maxStatus = challenge.maxStatus)
}
fun completePhoneLogin() = launchLoading {
val current = state.value
val challenge = current.phoneAuthChallenge ?: return@launchLoading
val code = current.maxCode.trim()
if (code.isBlank()) return@launchLoading
repository.completePhoneAuth(current.serverUrl, challenge.challengeId, challenge.challengeToken, code)
state.value = state.value.copy(phoneAuthChallenge = null, maxCode = "")
}
fun logout() = viewModelScope.launch {
@@ -203,6 +295,36 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
fun loadChats() = refreshChats()
fun loadContacts() {
val session = state.value.session ?: return
viewModelScope.launch {
state.value = state.value.copy(contactsLoading = true, contactsError = null)
runCatching { repository.contacts(session) }
.onSuccess { contacts ->
if (state.value.session?.serverUrl == session.serverUrl &&
state.value.session?.userName == session.userName
) {
state.value = state.value.copy(
contacts = contacts,
contactsLoading = false,
contactsError = null
)
}
}
.onFailure { error ->
if (error is CancellationException) throw error
if (state.value.session?.serverUrl == session.serverUrl &&
state.value.session?.userName == session.userName
) {
state.value = state.value.copy(
contactsLoading = false,
contactsError = error.message ?: "Не удалось загрузить контакты"
)
}
}
}
}
private suspend fun restoreCachedChats(session: QMaxSession) {
runCatching {
repository.cachedChats(session)
@@ -303,6 +425,80 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
loadChats()
}
fun openContact(contact: ContactDto) = launchLoading(false) {
val session = requireSession()
val existing = state.value.chats.firstOrNull { it.externalId == contact.externalChatId }
val chat = existing ?: repository.createChat(
session,
contact.externalChatId,
contact.displayName,
contact.avatarUrl
)
if (existing == null) {
state.value = state.value.copy(chats = normalizeChats(state.value.chats + chat))
}
openChat(chat)
loadChats()
}
fun 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) {
saveCurrentDraft()
chatSearchJob?.cancel()
@@ -316,6 +512,9 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
)
state.value = state.value.copy(
selectedChat = openedChat,
initialUnreadCount = chat.unreadCount.coerceAtLeast(0),
initialUnreadThrough = chat.lastMessageAt,
initialMessageLoadComplete = false,
chats = updatedChats,
messages = emptyList(),
chatSearchResults = emptyList(),
@@ -390,6 +589,9 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
}
state.value = state.value.copy(
selectedChat = null,
initialUnreadCount = 0,
initialUnreadThrough = null,
initialMessageLoadComplete = false,
chats = reorderedChats,
messages = emptyList(),
chatSearchResults = emptyList(),
@@ -433,6 +635,17 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
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) {
if (!showLoading && state.value.sendingMessage) {
return@launchLoading
@@ -451,6 +664,10 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
if (error.code == 404 && recoverMissingChat(session, chat)) {
return@launchLoading
}
markInitialMessageLoadComplete(chat.id)
throw error
} catch (error: Throwable) {
markInitialMessageLoadComplete(chat.id)
throw error
}
if (state.value.selectedChat?.id == chat.id) {
@@ -458,6 +675,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
selected?.let(::rememberChatRead)
state.value = state.value.copy(
selectedChat = selected,
initialMessageLoadComplete = true,
chats = normalizeChats(state.value.chats, selectedChatId = chat.id),
messages = fresh
)
@@ -512,6 +730,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
if (replacement == null) {
state.value = state.value.copy(
chats = orderedFresh,
initialMessageLoadComplete = true,
error = null
)
repository.cacheChats(session, orderedFresh)
@@ -528,6 +747,9 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
)
state.value = state.value.copy(
selectedChat = openedChat,
initialUnreadCount = maxOf(state.value.initialUnreadCount, replacement.unreadCount),
initialUnreadThrough = replacement.lastMessageAt ?: state.value.initialUnreadThrough,
initialMessageLoadComplete = false,
chats = chats,
messages = emptyList(),
chatPresenceText = null,
@@ -543,6 +765,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
if (state.value.selectedChat?.id == openedChat.id) {
state.value = state.value.copy(
selectedChat = state.value.selectedChat?.copy(unreadCount = 0),
initialMessageLoadComplete = true,
chats = normalizeChats(state.value.chats, selectedChatId = openedChat.id),
messages = freshMessages
)
@@ -646,6 +869,41 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
}
}
fun sendSharedContent(
targetChats: List<ChatDto>,
text: String?,
attachmentUris: List<Uri>,
onSuccess: () -> Unit = {}
) = launchLoading(false) {
if (targetChats.isEmpty()) return@launchLoading
val normalizedText = text?.trim().orEmpty()
if (normalizedText.isBlank() && attachmentUris.isEmpty()) return@launchLoading
val session = requireSession()
state.value = state.value.copy(sendingMessage = true, error = null)
try {
targetChats.forEach { chat ->
if (attachmentUris.isEmpty()) {
repository.sendMessage(session, chat.id, normalizedText, replyToMessageId = null)
} else {
attachmentUris.forEachIndexed { index, uri ->
repository.upload(
session = session,
chatId = chat.id,
uri = uri,
caption = normalizedText.takeIf { index == 0 && it.isNotBlank() },
replyToMessageId = null
)
}
}
}
onSuccess()
loadChats()
} finally {
state.value = state.value.copy(sendingMessage = false)
}
}
fun upload(uri: Uri) = launchLoading(false) {
val session = requireSession()
val chat = state.value.selectedChat ?: return@launchLoading
@@ -687,19 +945,20 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
fun loadMaxStatus() = launchLoading(false) {
val session = requireSession()
state.value = state.value.copy(maxStatus = repository.maxStatus(session))
refreshMaxStatus(session)
}
fun startMaxLogin() = launchLoading {
val session = requireSession()
state.value = state.value.copy(maxStatus = repository.startMaxLogin(session, null))
applyMaxStatus(repository.startMaxLogin(session, null))
}
fun submitMaxCode() = launchLoading {
val session = requireSession()
val code = state.value.maxCode.trim()
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 {
@@ -757,6 +1016,12 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
repository.shareAttachment(session, attachment)
}
fun saveImageToGallery(attachment: AttachmentDto, onSuccess: () -> Unit = {}) = launchLoading(false) {
val session = requireSession()
repository.saveImageToGallery(session, attachment)
onSuccess()
}
fun replyTo(message: MessageDto) {
state.value = state.value.copy(replyTarget = message, editTarget = null)
}
@@ -830,15 +1095,23 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
state.value = state.value.copy(forwardTarget = null)
}
fun forwardTo(chat: ChatDto) = launchLoading(false) {
fun forwardTo(targetChats: List<ChatDto>) = launchLoading(false) {
val session = requireSession()
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
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(
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 {
current.copy(forwardTarget = null)
@@ -1044,6 +1317,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) {
realtimeConnectJob?.cancel()
realtimeConnectJob = viewModelScope.launch {
@@ -1053,7 +1348,8 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
onChatListInvalidated = ::scheduleChatListRefresh,
onMessageCreated = ::handleRealtimeMessage,
onMessageUpdated = ::handleRealtimeMessageUpdated,
onMessageDeleted = ::handleRealtimeMessageDeleted
onMessageDeleted = ::handleRealtimeMessageDeleted,
onMaxStatusChanged = ::applyMaxStatus
)
realtime.joinChat(state.value.selectedChat?.id)
}
@@ -1205,6 +1501,12 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
}
}
private fun markInitialMessageLoadComplete(chatId: String) {
if (state.value.selectedChat?.id == chatId && !state.value.initialMessageLoadComplete) {
state.value = state.value.copy(initialMessageLoadComplete = true)
}
}
private fun syncChatRead(chatId: String) {
val session = state.value.session ?: return
viewModelScope.launch {
@@ -1245,6 +1547,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
const val ChatListTimeoutMs = 45_000L
const val MessageSyncTimeoutMs = 120_000L
const val MessageHydrationIntervalMs = 120_000L
const val MaxStatusPollIntervalMs = 60_000L
const val AutoLoginDelayMs = 1_000L
const val PushRegistrationAttempts = 6
const val InitialPushRegistrationRetryMs = 15_000L
@@ -1,35 +1,235 @@
package xyz.kusoft.qmax.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
import xyz.kusoft.qmax.core.settings.AppearanceSettings
import xyz.kusoft.qmax.core.settings.ChatPalette
import xyz.kusoft.qmax.core.settings.ThemeMode
val QMaxBlue = Color(0xFF3390EC)
val QMaxText = Color(0xFF17212B)
val QMaxMuted = Color(0xFF6C7883)
val QMaxBackground = Color(0xFFF3F7FA)
val QMaxIncoming = Color.White
val QMaxOutgoing = Color(0xFFE7F7C8)
val QMaxUnread = QMaxBlue
private val Colors: ColorScheme = lightColorScheme(
primary = QMaxBlue,
onPrimary = Color.White,
background = QMaxBackground,
onBackground = QMaxText,
surface = Color.White,
onSurface = QMaxText,
surfaceVariant = Color(0xFFE9EEF2),
onSurfaceVariant = QMaxMuted
private data class QMaxThemeColors(
val accent: Color,
val onAccent: Color,
val appBackground: Color,
val surface: Color,
val surfaceVariant: Color,
val divider: Color,
val text: Color,
val muted: Color,
val chatBackground: Color,
val incomingBubble: Color,
val outgoingBubble: Color,
val outgoingGradientColors: List<Color>,
val animateOutgoingGradient: Boolean,
val messageText: Color,
val pattern: Color,
val showPattern: Boolean,
val isDark: Boolean,
val chatFontSizeSp: Float,
val chatLineSpacingSp: Float
)
private val DefaultAppearance = AppearanceSettings()
private val DefaultColors = colorsFor(
palette = DefaultAppearance.lightPalette,
isDark = false,
chatFontSizeSp = DefaultAppearance.chatFontSizeSp,
chatLineSpacingSp = DefaultAppearance.chatLineSpacingSp
)
private val LocalQMaxColors = staticCompositionLocalOf { DefaultColors }
val QMaxBlue: Color
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.accent
val QMaxOnAccent: Color
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.onAccent
val QMaxText: Color
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.text
val QMaxMessageText: Color
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.messageText
val QMaxMuted: Color
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.muted
val QMaxBackground: Color
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.appBackground
val QMaxSurface: Color
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.surface
val QMaxSurfaceVariant: Color
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.surfaceVariant
val QMaxDivider: Color
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.divider
val QMaxChatBackground: Color
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.chatBackground
val QMaxIncoming: Color
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.incomingBubble
val QMaxOutgoing: Color
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.outgoingBubble
val QMaxOutgoingGradient: List<Color>
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.outgoingGradientColors
val QMaxAnimateOutgoingGradient: Boolean
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.animateOutgoingGradient
val QMaxPattern: Color
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.pattern
val QMaxShowPattern: Boolean
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.showPattern
val QMaxIsDarkTheme: Boolean
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.isDark
val QMaxChatTextStyle: TextStyle
@Composable
@ReadOnlyComposable
get() {
val colors = LocalQMaxColors.current
return MaterialTheme.typography.bodyLarge.copy(
fontSize = colors.chatFontSizeSp.sp,
lineHeight = (colors.chatFontSizeSp + colors.chatLineSpacingSp).sp
)
}
val QMaxUnread: Color
@Composable
@ReadOnlyComposable
get() = LocalQMaxColors.current.accent
@Composable
fun QMaxTheme(content: @Composable () -> Unit) {
fun QMaxTheme(
appearance: AppearanceSettings = AppearanceSettings(),
content: @Composable () -> Unit
) {
val isDark = when (appearance.mode) {
ThemeMode.System -> isSystemInDarkTheme()
ThemeMode.Light -> false
ThemeMode.Dark -> true
}
val colors = colorsFor(
palette = appearance.palette(isDark),
isDark = isDark,
chatFontSizeSp = appearance.chatFontSizeSp,
chatLineSpacingSp = appearance.chatLineSpacingSp
)
val materialColors = materialColorScheme(colors)
CompositionLocalProvider(LocalQMaxColors provides colors) {
MaterialTheme(
colorScheme = Colors,
colorScheme = materialColors,
typography = MaterialTheme.typography,
content = content
)
}
}
private fun colorsFor(
palette: ChatPalette,
isDark: Boolean,
chatFontSizeSp: Float,
chatLineSpacingSp: Float
): QMaxThemeColors {
val accent = Color(palette.accent)
return QMaxThemeColors(
accent = accent,
onAccent = if (accent.luminance() > 0.48f) Color.Black else Color.White,
appBackground = if (isDark) Color(0xFF111416) else Color(0xFFF2F2F4),
surface = if (isDark) Color(0xFF1C2023) else Color.White,
surfaceVariant = if (isDark) Color(0xFF292E32) else Color(0xFFF0F1F3),
divider = if (isDark) Color(0xFF30363A) else Color(0xFFE5E7E9),
text = if (isDark) Color(0xFFF4F7F7) else Color(0xFF111617),
muted = if (isDark) Color(0xFF9AA3A7) else Color(0xFF858D91),
chatBackground = Color(palette.chatBackground),
incomingBubble = Color(palette.incomingBubble),
outgoingBubble = Color(palette.outgoingBubble),
outgoingGradientColors = palette.outgoingGradientColors.map(::Color),
animateOutgoingGradient = palette.animateOutgoingGradient,
messageText = Color(palette.messageText),
pattern = Color(palette.patternColor),
showPattern = palette.showPattern,
isDark = isDark,
chatFontSizeSp = chatFontSizeSp,
chatLineSpacingSp = chatLineSpacingSp
)
}
private fun materialColorScheme(colors: QMaxThemeColors): ColorScheme {
return if (colors.isDark) {
darkColorScheme(
primary = colors.accent,
onPrimary = colors.onAccent,
background = colors.appBackground,
onBackground = colors.text,
surface = colors.surface,
onSurface = colors.text,
surfaceVariant = colors.surfaceVariant,
onSurfaceVariant = colors.muted,
outline = colors.divider
)
} else {
lightColorScheme(
primary = colors.accent,
onPrimary = colors.onAccent,
background = colors.appBackground,
onBackground = colors.text,
surface = colors.surface,
onSurface = colors.text,
surfaceVariant = colors.surfaceVariant,
onSurfaceVariant = colors.muted,
outline = colors.divider
)
}
}
+2 -7
View File
@@ -5,14 +5,9 @@ QMAX_TLS_EMAIL=admin@kusoft.xyz
# Generate with: openssl rand -base64 48
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.
# Optional registration code shared with people allowed to create a QMAX account.
# Leave empty for open phone registration. MAX still verifies every phone with its own 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.
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=
# Optional Firebase Cloud Messaging. Mount the service account JSON into the API container
+12 -12
View File
@@ -13,9 +13,8 @@ services:
QMax__ReleasesPath: /data/releases
QMax__JwtSecret: ${QMAX_JWT_SECRET}
QMax__PairingCode: ${QMAX_PAIRING_CODE}
QMax__MaxPhoneNumber: ${QMAX_MAX_PHONE_NUMBER}
QMax__MaxMode: Worker
QMax__MaxWorkerBaseUrl: http://qmax-max-worker:3001
QMax__MaxWorkerBaseUrl: http://qmax-pymax-worker:3002
QMax__CorsAllowedOrigins: ${QMAX_CORS_ALLOWED_ORIGINS:-}
QMax__PushEnabled: ${QMAX_PUSH_ENABLED:-true}
QMax__PushShowPreview: ${QMAX_PUSH_SHOW_PREVIEW:-true}
@@ -28,30 +27,31 @@ services:
ports:
- "127.0.0.1:18080:8080"
depends_on:
- qmax-max-worker
- qmax-pymax-worker
networks:
- qmax
qmax-max-worker:
qmax-pymax-worker:
build:
context: ../worker
context: ../pymax-worker
dockerfile: Dockerfile
container_name: qmax-max-worker
container_name: qmax-pymax-worker
restart: unless-stopped
environment:
PORT: 3001
MAX_BASE_URL: https://web.max.ru/
MAX_USER_DATA_DIR: /data/max-profile
MAX_HEADLESS: ${QMAX_MAX_HEADLESS:-true}
PORT: 3002
PYMAX_SESSION_DIR: /data/pymax-session
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:
- qmax-max-profile:/data
- qmax-pymax-session:/data
- qmax-data:/qmax-data
networks:
- qmax
volumes:
qmax-data:
qmax-max-profile:
qmax-pymax-session:
networks:
qmax:
+7 -7
View File
@@ -1,14 +1,14 @@
{
"slug": "qmax",
"name": "QMAX",
"version": "0.1.39",
"androidVersionCode": 40,
"version": "1.0.1",
"androidVersionCode": 58,
"channel": "stable",
"platform": "android",
"packageKind": "apk",
"downloadPath": "/api/app-updates/android/download/qmax-0.1.39-stable.apk",
"packageSizeBytes": 17730369,
"sha256": "fb1ba02daf4a5e99972794bd69965bef8219388c96344902b9d8a67ed2635803",
"notes": "Chat composer now follows the Android keyboard height like Telegram, and downloaded video, audio, and document attachments keep correct local cache extensions.",
"publishedAt": "2026-07-02T18:38:00.3130417Z"
"downloadPath": "/api/app-updates/android/download/qmax-1.0.1-stable.apk",
"packageSizeBytes": 17976753,
"sha256": "ff59d69d2b783d53fbd100ffb4354c150f9c342c5a2b24bb4ea9063bac98bcf9",
"notes": "QMAX 1.0.1",
"publishedAt": "2026-07-15T21:06:52.7275098Z"
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14 -10
View File
@@ -4,33 +4,37 @@
flowchart LR
A["Android app<br/>Kotlin + Compose"] -->|HTTPS JSON + uploads| B["QMAX API<br/>ASP.NET Core"]
A -->|SignalR planned/available| B
B --> C["SQLite cache<br/>chats/messages/sessions"]
B --> C["SQLite cache<br/>tenant-scoped chats/messages/sessions"]
B --> D["Local storage<br/>attachments/releases"]
B -->|HTTP internal| E["MAX worker<br/>Node + Playwright"]
E -->|persistent browser profile| F["web.max.ru"]
B -->|HTTP internal| E["MAX worker<br/>Python + PyMax"]
E -->|isolated session per user| F["MAX mobile API"]
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
- Android pairs to the private server with `QMAX_PAIRING_CODE`.
- Android starts a short-lived phone challenge; JWT credentials are issued only after PyMax confirms the MAX code.
- `QMAX_PAIRING_CODE` is an optional registration gate, not a substitute for MAX phone verification.
- API access uses JWT access tokens and refresh tokens.
- MAX credentials and sessions live only on the Pi in Docker volumes.
- Chats, messages, push devices, realtime notifications and MAX state are scoped by QMAX user id.
- MAX session files live only on the Pi in per-user Docker-volume directories.
- `.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.
- Android image attachments are downloaded to a local `.part` cache and exposed to the UI only after size validation.
## Current Verified Status
## Implemented Status
- MAX Web login is authorized on the Raspberry Pi worker.
- Chat list, message history, text sending and attachment sending are mapped through `worker/src/server.js`.
- Phone challenge routing and per-user PyMax session directories are implemented in `pymax-worker/src/server.py`.
- 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.
- Firebase initialization and Android push token registration are enabled for `xyz.kusoft.qmax`.
Live multi-account authorization against MAX and Raspberry Pi capacity were not verified in this workspace.
## 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.
- 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 @@
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Any, Mapping
def normalize_chat_kind_value(value: Any) -> str:
raw = getattr(value, "value", value)
text = str(raw or "").strip().upper()
if text.startswith("CHATTYPE."):
text = text.removeprefix("CHATTYPE.")
if text == "CHANNEL":
return "Channel"
if text in {"CHAT", "GROUP"}:
return "Group"
return "MaxDialog"
def resolve_sender_name(
sender_id: int | None,
*,
is_outgoing: bool,
phone_contact_names: Mapping[str, str] | None,
profile_name: str | None,
) -> str | None:
if is_outgoing:
return "You"
if sender_id is not None:
phone_name = (phone_contact_names or {}).get(str(sender_id))
if phone_name and phone_name.strip():
return phone_name.strip()
return profile_name.strip() if profile_name and profile_name.strip() else None
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from typing import Any
INCOMING_CALL_TEXT = "\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a"
def is_call_media_label(value: Any) -> bool:
if value is None:
return False
text = str(value).strip().lower()
return text == "call" or text.startswith("call-")
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
import unittest
from enum import Enum
from src.chat_identity import normalize_chat_kind_value, resolve_sender_name
class SampleChatType(str, Enum):
DIALOG = "DIALOG"
CHAT = "CHAT"
CHANNEL = "CHANNEL"
class ChatIdentityTests(unittest.TestCase):
def test_chat_kind_is_normalized(self) -> None:
cases = {
SampleChatType.DIALOG: "MaxDialog",
SampleChatType.CHAT: "Group",
SampleChatType.CHANNEL: "Channel",
"ChatType.CHAT": "Group",
"GROUP": "Group",
None: "MaxDialog",
}
for value, expected in cases.items():
with self.subTest(value=value):
self.assertEqual(expected, normalize_chat_kind_value(value))
def test_phone_book_name_has_priority(self) -> None:
self.assertEqual(
"Мама",
resolve_sender_name(
42,
is_outgoing=False,
phone_contact_names={"42": " Мама "},
profile_name="MAX Profile",
),
)
def test_profile_name_is_used_when_phone_name_is_missing(self) -> None:
self.assertEqual(
"MAX Profile",
resolve_sender_name(
42,
is_outgoing=False,
phone_contact_names={},
profile_name="MAX Profile",
),
)
def test_outgoing_message_uses_you(self) -> None:
self.assertEqual(
"You",
resolve_sender_name(
42,
is_outgoing=True,
phone_contact_names={"42": "Мама"},
profile_name="MAX Profile",
),
)
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -0,0 +1,22 @@
import unittest
from src.message_labels import INCOMING_CALL_TEXT, is_call_media_label
class MessageLabelsTests(unittest.TestCase):
def test_call_media_labels_are_detected(self) -> None:
for value in ("call", "call-start", "CALL-MISSED", " call-ended "):
with self.subTest(value=value):
self.assertTrue(is_call_media_label(value))
def test_unrelated_values_are_not_detected(self) -> None:
for value in (None, "", "callback", "video-call", "phone"):
with self.subTest(value=value):
self.assertFalse(is_call_media_label(value))
def test_incoming_call_text_is_stable(self) -> None:
self.assertEqual("\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a", INCOMING_CALL_TEXT)
if __name__ == "__main__":
unittest.main()
+4 -3
View File
@@ -23,14 +23,15 @@ if (Test-Path $staging) {
}
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
}
foreach ($path in @(
"server/QMax.Api/bin",
"server/QMax.Api/obj",
"worker/node_modules"
"deploy/releases",
"pymax-worker/src/__pycache__"
)) {
$target = Join-Path $staging $path
if (Test-Path $target) {
@@ -40,7 +41,7 @@ foreach ($path in @(
Push-Location $staging
try {
tar -czf $archive server worker deploy Dockerfile.server QMax.slnx
tar -czf $archive server pymax-worker deploy Dockerfile.server QMax.slnx
}
finally {
Pop-Location
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
cd "${1:-/home/sevenhill/qmax}/deploy"
docker compose stop
trap 'docker compose start >/dev/null 2>&1 || true' EXIT
legacy_phone="$(sed -n 's/^QMAX_MAX_PHONE_NUMBER=//p' .env | tail -n 1)"
docker run --rm -i --user 0 --entrypoint python \
-e LEGACY_PHONE="$legacy_phone" \
--mount type=volume,source=deploy_qmax-data,target=/qmax \
--mount type=volume,source=deploy_qmax-pymax-session,target=/sessions \
deploy-qmax-pymax-worker - <<'PY'
import os
import pathlib
import shutil
import sqlite3
db = sqlite3.connect("/qmax/qmax.db")
row = db.execute("""
select u.Id, coalesce(nullif(u.PhoneNumber, ''), nullif(s.PhoneNumber, ''))
from Users u
left join MaxAccountStates s on s.UserId = u.Id
order by u.CreatedAt
limit 1
""").fetchone()
if not row:
raise SystemExit("No legacy QMAX user found")
user_id = str(row[0]).replace("-", "").lower()
phone = str(row[1] or os.environ.get("LEGACY_PHONE") or "").strip()
if not phone:
raise SystemExit("Legacy QMAX user has no phone number")
root = pathlib.Path("/sessions/pymax-session")
source = root / "session.db"
target = root / "accounts" / user_id
if not source.is_file():
raise SystemExit(f"Legacy PyMax session is missing: {source}")
target.mkdir(parents=True, exist_ok=True)
os.chmod(target, 0o700)
if not (target / "session.db").exists():
shutil.copy2(source, target / "session.db")
contacts = root / "phone-contact-ids.json"
if contacts.is_file() and not (target / contacts.name).exists():
shutil.copy2(contacts, target / contacts.name)
(target / "phone.txt").write_text(phone, encoding="utf-8")
os.chmod(target / "phone.txt", 0o600)
print(f"MIGRATED_ACCOUNT={user_id}")
print(f"SESSION_BYTES={(target / 'session.db').stat().st_size}")
PY
docker compose start
trap - EXIT
test "$(docker compose ps --status running --services | wc -l)" -eq 2
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import mimetypes
import os
import re
import shutil
import sqlite3
import uuid
from datetime import UTC, datetime
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description="Publish a QMAX Android APK to the local Argus data store.")
parser.add_argument("source", type=Path)
parser.add_argument("version")
parser.add_argument("notes")
args = parser.parse_args()
source = args.source.resolve(strict=True)
data_root = Path(os.environ.get("ARGUS_DATA", "/mnt/data/argus")).resolve(strict=True)
db_path = data_root / "argus.db"
packages_root = data_root / "Packages"
slug = "qmax"
channel = "stable"
platform = "android"
release_id = str(uuid.uuid4()).upper()
now = datetime.now(UTC).isoformat(timespec="microseconds")
safe_version = re.sub(r"[^a-zA-Z0-9._-]+", "-", args.version).strip("-._")
stored_name = f"{datetime.now(UTC):%Y%m%d%H%M%S}-{safe_version}-{release_id.replace('-', '')}.apk"
stored_relative_path = f"{slug}/{stored_name}"
target_path = packages_root / stored_relative_path
target_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target_path)
size_bytes = target_path.stat().st_size
sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest()
content_type = mimetypes.guess_type(source.name)[0] or "application/vnd.android.package-archive"
connection = sqlite3.connect(db_path)
try:
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("BEGIN")
app_id = connection.execute('SELECT "Id" FROM "Apps" WHERE "Slug" = ?', (slug,)).fetchone()[0]
connection.execute(
'''
UPDATE "Apps"
SET "Name" = ?, "Summary" = ?, "Description" = ?, "RepositoryUrl" = ?,
"HomepageUrl" = ?, "IsListed" = 1, "UpdatedAt" = ?
WHERE "Id" = ?
''',
(
"QMAX",
"Multi-user Android client for MAX through a self-hosted PyMax bridge.",
"QMAX connects multiple Android users to isolated MAX accounts through one self-hosted server and per-user PyMax sessions.",
"https://git.kusoft.xyz/sevenhill/QMAX",
"https://qmax.kusoft.xyz",
now,
app_id,
),
)
duplicate = connection.execute(
'SELECT 1 FROM "Releases" WHERE "CatalogAppId" = ? AND "Version" = ? AND "Channel" = ? AND "Platform" = ?',
(app_id, args.version, channel, platform),
).fetchone()
if duplicate:
raise RuntimeError(f"QMAX {args.version} is already published")
connection.execute(
'''
INSERT INTO "Releases"
("Id", "CatalogAppId", "Version", "Channel", "Platform", "PackageKind",
"OriginalFileName", "StoredRelativePath", "ContentType", "PackageSizeBytes",
"Sha256", "Notes", "PublishedAt")
VALUES (?, ?, ?, ?, ?, 'apk', ?, ?, ?, ?, ?, ?, ?)
''',
(
release_id,
app_id,
args.version,
channel,
platform,
source.name,
stored_relative_path,
content_type,
size_bytes,
sha256,
args.notes,
now,
),
)
connection.commit()
except Exception:
connection.rollback()
target_path.unlink(missing_ok=True)
raise
finally:
connection.close()
print(f"PUBLISHED={args.version}")
print(f"SIZE={size_bytes}")
print(f"SHA256={sha256}")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -11,7 +11,7 @@ public sealed class QMaxOptions
public string PairingCode { get; set; } = "";
public string MaxPhoneNumber { get; set; } = "";
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 bool MaxHeadless { get; set; } = true;
public int MaxPollIntervalSeconds { get; set; } = 6;
@@ -1,6 +1,9 @@
namespace QMax.Api.Contracts;
public sealed record DeviceLoginRequest(string PairingCode, string DeviceName);
public sealed record BeginPhoneAuthRequest(string PhoneNumber, string DeviceName, string? RegistrationCode = null);
public sealed record CompletePhoneAuthRequest(Guid ChallengeId, string ChallengeToken, string Code);
public sealed record PhoneAuthChallengeResponse(Guid ChallengeId, string ChallengeToken, MaxBridgeStatusDto MaxStatus, DateTimeOffset ExpiresAt);
public sealed record RefreshTokenRequest(string RefreshToken);
public sealed record AuthResponse(string AccessToken, string RefreshToken, DateTimeOffset ExpiresAt, UserDto User);
public sealed record UserDto(Guid Id, string DisplayName, string? PhoneNumber, string? AvatarPath);
@@ -52,3 +52,16 @@ public sealed record CreateDirectChatRequest(string ExternalChatId, string Title
public sealed record ChatBulkActionRequest(IReadOnlyList<Guid> ChatIds);
public sealed record ChatPresenceDto(bool IsTyping, string? StatusText, DateTimeOffset UpdatedAt);
public sealed record ContactDto(
string UserId,
string ExternalChatId,
string DisplayName,
string? AvatarUrl,
string? PhoneNumber,
string? Status,
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 ScreenshotPngBase64,
DateTimeOffset CapturedAt);
public sealed record MaxChannelSearchResultDto(
string ExternalId,
string Title,
string? AvatarUrl,
string? ChatUrl,
bool IsSubscribed,
string? Description);
public sealed record SubscribeMaxChannelRequest(string Link);
+143 -1
View File
@@ -7,6 +7,7 @@ using QMax.Api.Contracts;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Auth;
using QMax.Api.Infrastructure.Max;
namespace QMax.Api.Controllers;
@@ -15,10 +16,117 @@ namespace QMax.Api.Controllers;
public sealed class AuthController(
QMaxDbContext db,
ITokenService tokenService,
IOptions<QMaxOptions> options) : ControllerBase
IOptions<QMaxOptions> options,
IMaxBridgeClient maxBridge,
ICurrentUserAccessor currentUser) : ControllerBase
{
private readonly QMaxOptions _options = options.Value;
[AllowAnonymous]
[HttpPost("phone/start")]
public async Task<ActionResult<PhoneAuthChallengeResponse>> BeginPhoneAuth(
BeginPhoneAuthRequest request,
CancellationToken cancellationToken)
{
if (!RegistrationCodeIsValid(request.RegistrationCode))
{
return Unauthorized();
}
var phone = NormalizePhone(request.PhoneNumber);
if (phone is null)
{
return BadRequest("Phone number must contain 10 to 15 digits.");
}
var user = await db.Users.FirstOrDefaultAsync(x => x.PhoneNumber == phone, cancellationToken);
if (user is null)
{
user = new User { DisplayName = phone, PhoneNumber = phone };
db.Users.Add(user);
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
db.Entry(user).State = EntityState.Detached;
user = await db.Users.FirstAsync(x => x.PhoneNumber == phone, cancellationToken);
}
}
var retryAfter = DateTimeOffset.UtcNow.AddSeconds(-60);
var recentChallengeTimes = await db.MaxLoginChallenges.IgnoreQueryFilters()
.Where(x => x.UserId == user.Id && x.CompletedAt == null)
.Select(x => x.CreatedAt)
.ToArrayAsync(cancellationToken);
if (recentChallengeTimes.Any(x => x >= retryAfter))
{
return StatusCode(StatusCodes.Status429TooManyRequests, "Wait 60 seconds before requesting another MAX code.");
}
var challengeToken = tokenService.CreateRefreshToken();
var challenge = new MaxLoginChallenge
{
UserId = user.Id,
DeviceName = string.IsNullOrWhiteSpace(request.DeviceName) ? "Android" : request.DeviceName.Trim(),
SecretHash = tokenService.HashRefreshToken(challengeToken)
};
using var tenant = currentUser.Push(user.Id);
db.MaxLoginChallenges.Add(challenge);
await db.SaveChangesAsync(cancellationToken);
var status = await maxBridge.BeginNewPhoneLoginAsync(phone, cancellationToken);
await SaveMaxStateAsync(user.Id, phone, status, cancellationToken);
return new PhoneAuthChallengeResponse(
challenge.Id,
challengeToken,
ToDto(status),
challenge.ExpiresAt);
}
[AllowAnonymous]
[HttpPost("phone/code")]
public async Task<ActionResult<AuthResponse>> CompletePhoneAuth(
CompletePhoneAuthRequest request,
CancellationToken cancellationToken)
{
var hash = tokenService.HashRefreshToken(request.ChallengeToken ?? "");
var challenge = await db.MaxLoginChallenges
.IgnoreQueryFilters()
.Include(x => x.User)
.FirstOrDefaultAsync(x => x.Id == request.ChallengeId && x.SecretHash == hash, cancellationToken);
if (challenge?.User is null || challenge.CompletedAt is not null ||
challenge.ExpiresAt <= DateTimeOffset.UtcNow || challenge.FailedAttempts >= 5)
{
return Unauthorized();
}
using var tenant = currentUser.Push(challenge.UserId);
var status = await maxBridge.SubmitLoginCodeAsync(request.Code?.Trim() ?? "", cancellationToken);
await SaveMaxStateAsync(challenge.UserId, challenge.User.PhoneNumber ?? "", status, cancellationToken);
if (!status.IsAuthorized)
{
challenge.FailedAttempts++;
await db.SaveChangesAsync(cancellationToken);
return Conflict(ToDto(status));
}
challenge.CompletedAt = DateTimeOffset.UtcNow;
challenge.User.UpdatedAt = DateTimeOffset.UtcNow;
var refreshToken = tokenService.CreateRefreshToken();
var session = new UserSession
{
User = challenge.User,
DeviceName = challenge.DeviceName,
RefreshTokenHash = tokenService.HashRefreshToken(refreshToken)
};
db.UserSessions.Add(session);
await db.SaveChangesAsync(cancellationToken);
return CreateAuthResponse(challenge.User, session, refreshToken);
}
[AllowAnonymous]
[HttpPost("device/login")]
public async Task<ActionResult<AuthResponse>> Login(DeviceLoginRequest request, CancellationToken cancellationToken)
@@ -127,4 +235,38 @@ public sealed class AuthController(
return expectedBytes.Length == actualBytes.Length &&
System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes);
}
private bool RegistrationCodeIsValid(string? supplied)
{
return string.IsNullOrWhiteSpace(_options.PairingCode) ||
FixedTimeEquals(_options.PairingCode, supplied ?? "");
}
private static string? NormalizePhone(string? value)
{
var digits = new string((value ?? "").Where(char.IsDigit).ToArray());
if (digits.Length == 11 && digits[0] == '8') digits = "7" + digits[1..];
if (digits.Length == 10) digits = "7" + digits;
return digits.Length is >= 10 and <= 15 ? "+" + digits : null;
}
private async Task SaveMaxStateAsync(Guid userId, string phone, MaxBridgeStatus status, CancellationToken cancellationToken)
{
var state = await db.MaxAccountStates.FirstOrDefaultAsync(cancellationToken);
if (state is null)
{
state = new MaxAccountState { UserId = userId };
db.MaxAccountStates.Add(state);
}
state.PhoneNumber = phone;
state.Status = status.Status;
state.IsAuthorized = status.IsAuthorized;
state.LastUrl = status.Url;
state.LastError = status.LastError;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status) =>
new(status.Mode, status.IsAuthorized, status.LoginStage, status.Status, status.Url, status.Title, status.LastError, status.UpdatedAt);
}
+62 -13
View File
@@ -8,6 +8,7 @@ using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage;
using QMax.Api.Infrastructure.Auth;
using QMax.Api.Services;
namespace QMax.Api.Controllers;
@@ -51,10 +52,20 @@ public sealed class ChatsController(
Kind = ChatKind.MaxDialog
};
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 hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return projection.ToDto(chat);
}
@@ -92,7 +103,7 @@ public sealed class ChatsController(
{
chat.UnreadCount = 0;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
}
var query = db.Messages
@@ -230,7 +241,7 @@ public sealed class ChatsController(
chat.UnreadCount = 0;
chat.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
}
return NoContent();
@@ -269,12 +280,12 @@ public sealed class ChatsController(
await db.SaveChangesAsync(cancellationToken);
DeleteFiles(files);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return NoContent();
}
[HttpPost("delete")]
public IActionResult DeleteChats(ChatBulkActionRequest request)
public async Task<IActionResult> DeleteChats(ChatBulkActionRequest request, CancellationToken cancellationToken)
{
var chatIds = request.ChatIds.Distinct().ToArray();
if (chatIds.Length == 0)
@@ -282,7 +293,43 @@ public sealed class ChatsController(
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.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return NoContent();
}
[HttpGet("{chatId:guid}/search")]
@@ -404,7 +451,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
@@ -458,7 +505,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
@@ -495,7 +542,7 @@ public sealed class ChatsController(
}
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageDeleted", new MessageDeletedDto(chatId, messageId), cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return NoContent();
}
@@ -633,6 +680,8 @@ public sealed class ChatsController(
Direction = MessageDirection.Outgoing,
Text = source.Text,
ForwardedFrom = ResolveForwardedFrom(source),
ForwardedFromExternalChatId = source.ExternalId is null ? null : source.Chat?.ExternalId,
ForwardedFromExternalMessageId = source.ExternalId,
SentAt = DateTimeOffset.UtcNow,
DeliveryState = MessageDeliveryState.Sending,
SenderName = "You"
@@ -671,7 +720,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{target.Id}").SendAsync("MessageCreated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
@@ -739,7 +788,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
@@ -883,7 +932,7 @@ public sealed class ChatsController(
chat.WebUrl = nextWebUrl;
chat.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return chat.WebUrl;
}
@@ -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);
}
+66 -7
View File
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@@ -6,8 +7,10 @@ using QMax.Api.Configuration;
using QMax.Api.Contracts;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Services;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Controllers;
@@ -18,10 +21,9 @@ public sealed class MaxController(
IMaxBridgeClient maxBridge,
MaxBridgeSyncService syncService,
QMaxDbContext db,
IOptions<QMaxOptions> options) : ControllerBase
IHubContext<QMaxHub> hubContext,
ChatProjectionService projection) : ControllerBase
{
private readonly QMaxOptions _options = options.Value;
[HttpGet("status")]
public async Task<ActionResult<MaxBridgeStatusDto>> Status(CancellationToken cancellationToken)
{
@@ -33,7 +35,12 @@ public sealed class MaxController(
[HttpPost("login/start")]
public async Task<ActionResult<MaxBridgeStatusDto>> BeginLogin(BeginMaxLoginRequest request, CancellationToken cancellationToken)
{
var phone = string.IsNullOrWhiteSpace(request.PhoneNumber) ? _options.MaxPhoneNumber : request.PhoneNumber;
var phone = request.PhoneNumber;
if (string.IsNullOrWhiteSpace(phone))
{
var userId = User.GetUserId();
phone = await db.Users.Where(x => x.Id == userId).Select(x => x.PhoneNumber).FirstOrDefaultAsync(cancellationToken);
}
if (string.IsNullOrWhiteSpace(phone))
{
return BadRequest("Phone number is required.");
@@ -66,26 +73,78 @@ public sealed class MaxController(
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.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return projection.ToDto(chat);
}
private async Task SaveStateAsync(MaxBridgeStatus status, CancellationToken cancellationToken)
{
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
var userId = User.GetUserId();
var state = await db.MaxAccountStates.FirstOrDefaultAsync(cancellationToken);
if (state is null)
{
state = new MaxAccountState { Id = 1 };
state = new MaxAccountState { UserId = userId };
db.MaxAccountStates.Add(state);
}
state.PhoneNumber = _options.MaxPhoneNumber;
state.PhoneNumber = await db.Users.Where(x => x.Id == userId).Select(x => x.PhoneNumber).FirstOrDefaultAsync(cancellationToken) ?? "";
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.User(userId.ToString()).SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
}
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 static MaxChannelSearchResultDto ToDto(MaxChannelSearchResult channel)
{
return new MaxChannelSearchResultDto(channel.ExternalId, channel.Title, channel.AvatarUrl, channel.ChatUrl, channel.IsSubscribed, channel.Description);
}
}
+2
View File
@@ -3,6 +3,8 @@ namespace QMax.Api.Data.Entities;
public sealed class Chat
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? UserId { get; set; }
public User? User { get; set; }
public string? ExternalId { get; set; }
public ChatKind Kind { get; set; } = ChatKind.MaxDialog;
public string Title { get; set; } = "MAX chat";
@@ -2,7 +2,8 @@ namespace QMax.Api.Data.Entities;
public sealed class MaxAccountState
{
public int Id { get; set; } = 1;
public Guid UserId { get; set; }
public User? User { get; set; }
public string PhoneNumber { get; set; } = "";
public string Status { get; set; } = "NotStarted";
public bool IsAuthorized { get; set; }
@@ -0,0 +1,14 @@
namespace QMax.Api.Data.Entities;
public sealed class MaxLoginChallenge
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public User? User { get; set; }
public string SecretHash { get; set; } = "";
public string DeviceName { get; set; } = "Android";
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddMinutes(10);
public DateTimeOffset? CompletedAt { get; set; }
public int FailedAttempts { get; set; }
}
+2
View File
@@ -15,6 +15,8 @@ public sealed class Message
public DateTimeOffset? DeletedAt { get; set; }
public Guid? ReplyToMessageId { get; set; }
public string? ForwardedFrom { get; set; }
public string? ForwardedFromExternalChatId { get; set; }
public string? ForwardedFromExternalMessageId { get; set; }
public MessageDeliveryState DeliveryState { get; set; } = MessageDeliveryState.Sent;
public string? Error { get; set; }
public string? MediaAlbumId { get; set; }
+18 -1
View File
@@ -80,7 +80,24 @@ public static class QMaxDatabaseCleanup
WHERE message.Direction = 'Outgoing'
AND localAttachment.Id <> remoteAttachment.Id
AND localAttachment.SortOrder = remoteAttachment.SortOrder
AND localAttachment.Kind = remoteAttachment.Kind
AND (
localAttachment.Kind = remoteAttachment.Kind OR
(
localAttachment.Kind = 'VoiceNote'
AND remoteAttachment.Kind = 'File'
AND localAttachment.FileSizeBytes = remoteAttachment.FileSizeBytes
AND (
lower(remoteAttachment.ContentType) LIKE 'audio/%' OR
lower(remoteAttachment.OriginalFileName) LIKE '%.m4a' OR
lower(remoteAttachment.OriginalFileName) LIKE '%.ogg' OR
lower(remoteAttachment.OriginalFileName) LIKE '%.opus' OR
lower(remoteAttachment.OriginalFileName) LIKE '%.aac' OR
lower(remoteAttachment.OriginalFileName) LIKE '%.mp3' OR
lower(remoteAttachment.OriginalFileName) LIKE '%.wav' OR
lower(remoteAttachment.OriginalFileName) LIKE '%.flac'
)
)
)
AND (localAttachment.ExternalId IS NULL OR trim(localAttachment.ExternalId) = '')
AND (localAttachment.RemoteUrl IS NULL OR trim(localAttachment.RemoteUrl) = '')
AND localAttachment.StorageFileName NOT LIKE 'remote-%'
+51 -3
View File
@@ -1,10 +1,13 @@
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Data;
public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbContext(options)
public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options, ICurrentUserAccessor? currentUser = null) : DbContext(options)
{
private Guid? TenantUserId => currentUser?.UserId;
private bool TenantBypass => currentUser is null || currentUser.BypassTenantFilter || currentUser.UserId is null;
public DbSet<User> Users => Set<User>();
public DbSet<UserSession> UserSessions => Set<UserSession>();
public DbSet<Chat> Chats => Set<Chat>();
@@ -13,12 +16,20 @@ public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbC
public DbSet<MessageReaction> MessageReactions => Set<MessageReaction>();
public DbSet<PushDevice> PushDevices => Set<PushDevice>();
public DbSet<MaxAccountState> MaxAccountStates => Set<MaxAccountState>();
public DbSet<MaxLoginChallenge> MaxLoginChallenges => Set<MaxLoginChallenge>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasIndex(x => x.PhoneNumber);
modelBuilder.Entity<User>().HasIndex(x => x.PhoneNumber).IsUnique();
modelBuilder.Entity<MaxLoginChallenge>().HasIndex(x => x.SecretHash).IsUnique();
modelBuilder.Entity<MaxAccountState>().HasKey(x => x.UserId);
modelBuilder.Entity<MaxAccountState>()
.HasOne(x => x.User)
.WithOne()
.HasForeignKey<MaxAccountState>(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<UserSession>().HasIndex(x => x.RefreshTokenHash).IsUnique();
modelBuilder.Entity<Chat>().HasIndex(x => x.ExternalId).IsUnique();
modelBuilder.Entity<Chat>().HasIndex(x => new { x.UserId, x.ExternalId }).IsUnique();
modelBuilder.Entity<Chat>().HasIndex(x => x.DeletedAt);
modelBuilder.Entity<Chat>().HasIndex(x => new { x.PendingMaxAction, x.PendingMaxActionRequestedAt });
modelBuilder.Entity<Message>().HasIndex(x => new { x.ChatId, x.SentAt });
@@ -28,6 +39,21 @@ public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbC
modelBuilder.Entity<MessageReaction>().HasIndex(x => new { x.MessageId, x.ActorKey }).IsUnique();
modelBuilder.Entity<PushDevice>().HasIndex(x => x.FirebaseToken).IsUnique();
modelBuilder.Entity<Chat>().HasQueryFilter(x =>
TenantBypass || x.UserId == TenantUserId);
modelBuilder.Entity<Message>().HasQueryFilter(x =>
TenantBypass || (x.Chat != null && x.Chat.UserId == TenantUserId));
modelBuilder.Entity<MessageAttachment>().HasQueryFilter(x =>
TenantBypass || (x.Message != null && x.Message.Chat != null && x.Message.Chat.UserId == TenantUserId));
modelBuilder.Entity<MessageReaction>().HasQueryFilter(x =>
TenantBypass || (x.Message != null && x.Message.Chat != null && x.Message.Chat.UserId == TenantUserId));
modelBuilder.Entity<PushDevice>().HasQueryFilter(x =>
TenantBypass || x.UserId == TenantUserId);
modelBuilder.Entity<MaxAccountState>().HasQueryFilter(x =>
TenantBypass || x.UserId == TenantUserId);
modelBuilder.Entity<MaxLoginChallenge>().HasQueryFilter(x =>
TenantBypass || x.UserId == TenantUserId);
modelBuilder.Entity<Chat>()
.Property(x => x.Kind)
.HasConversion<string>();
@@ -60,4 +86,26 @@ public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbC
.HasForeignKey(x => x.MessageId)
.OnDelete(DeleteBehavior.Cascade);
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var newChats = ChangeTracker.Entries<Chat>()
.Where(x => x.State == EntityState.Added && x.Entity.UserId is null)
.ToArray();
var userId = currentUser?.UserId;
if (userId is null && currentUser is not null && newChats.Length > 0)
{
var existingUsers = await Users.Select(x => x.Id).Take(2).ToArrayAsync(cancellationToken);
if (existingUsers.Length == 1) userId = existingUsers[0];
}
if (userId is not null)
{
foreach (var entry in newChats)
{
entry.Entity.UserId = userId;
}
}
return await base.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,37 @@
namespace QMax.Api.Infrastructure.Auth;
public interface ICurrentUserAccessor
{
Guid? UserId { get; }
bool BypassTenantFilter { get; }
IDisposable Push(Guid? userId, bool bypassTenantFilter = false);
}
public sealed class CurrentUserAccessor : ICurrentUserAccessor
{
private static readonly AsyncLocal<State?> Current = new();
public Guid? UserId => Current.Value?.UserId;
public bool BypassTenantFilter => Current.Value?.BypassTenantFilter == true;
public IDisposable Push(Guid? userId, bool bypassTenantFilter = false)
{
var previous = Current.Value;
Current.Value = new State(userId, bypassTenantFilter);
return new PopScope(previous);
}
private sealed record State(Guid? UserId, bool BypassTenantFilter);
private sealed class PopScope(State? previous) : IDisposable
{
private bool _disposed;
public void Dispose()
{
if (_disposed) return;
Current.Value = previous;
_disposed = true;
}
}
}
+10 -3
View File
@@ -1,14 +1,21 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Infrastructure.Hubs;
[Authorize]
public sealed class QMaxHub : Hub
public sealed class QMaxHub(QMaxDbContext db) : Hub
{
public Task JoinChat(string chatId)
public async Task JoinChat(string chatId)
{
return Groups.AddToGroupAsync(Context.ConnectionId, $"chat:{chatId}");
if (!Guid.TryParse(chatId, out var id) || !await db.Chats.AnyAsync(x => x.Id == id && x.UserId == Context.User!.GetUserId()))
{
throw new HubException("Chat not found.");
}
await Groups.AddToGroupAsync(Context.ConnectionId, $"chat:{id}");
}
public Task LeaveChat(string chatId)
@@ -4,9 +4,27 @@ public interface IMaxBridgeClient
{
Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken);
Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken);
Task<MaxBridgeStatus> BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) =>
BeginPhoneLoginAsync(phoneNumber, cancellationToken);
Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken);
Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<MaxContact>>(Array.Empty<MaxContact>());
}
Task<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<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken);
@@ -14,9 +32,26 @@ public interface IMaxBridgeClient
Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken);
Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken);
Task<MaxSendResult> ForwardMessageAsync(
string externalChatId,
string sourceExternalChatId,
string sourceExternalMessageId,
CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(false, null, "Native forwarding is not supported."));
}
Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken);
Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, 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<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,
string? StatusText,
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",
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)
])
];
@@ -45,6 +45,31 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates);
}
public Task<MaxBridgeStatus> BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) =>
BeginPhoneLoginAsync(phoneNumber, cancellationToken);
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)
{
var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId);
@@ -68,9 +93,7 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(
false,
"Chat deletion is disabled because MAX does not remove chats from the web client."));
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
@@ -83,6 +106,15 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
return Task.FromResult(new MaxSendResult(true, $"mock-file-{Guid.NewGuid():N}", null, MockChatUrl(externalChatId)));
}
public Task<MaxSendResult> ForwardMessageAsync(
string externalChatId,
string sourceExternalChatId,
string sourceExternalMessageId,
CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(true, $"mock-forward-{Guid.NewGuid():N}", null, MockChatUrl(externalChatId)));
}
public Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
@@ -108,8 +140,32 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
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)
{
return $"https://web.max.ru/mock/{Uri.EscapeDataString(externalChatId)}";
return $"pymax://mock/{Uri.EscapeDataString(externalChatId)}";
}
}
@@ -2,12 +2,14 @@ using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Infrastructure.Max;
public sealed class WorkerMaxBridgeClient(
HttpClient httpClient,
IOptions<QMaxOptions> options,
ICurrentUserAccessor currentUser,
ILogger<WorkerMaxBridgeClient> logger) : IMaxBridgeClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
@@ -21,7 +23,17 @@ public sealed class WorkerMaxBridgeClient(
public async Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
{
return await SendAsync<MaxBridgeStatus>(HttpMethod.Post, "/login/start", new { phoneNumber }, cancellationToken)
return await BeginPhoneLoginCoreAsync(phoneNumber, forceNewSession: false, cancellationToken);
}
public async Task<MaxBridgeStatus> BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
{
return await BeginPhoneLoginCoreAsync(phoneNumber, forceNewSession: true, cancellationToken);
}
private async Task<MaxBridgeStatus> BeginPhoneLoginCoreAsync(string phoneNumber, bool forceNewSession, CancellationToken cancellationToken)
{
return await SendAsync<MaxBridgeStatus>(HttpMethod.Post, "/login/start", new { phoneNumber, force = forceNewSession }, cancellationToken)
?? ErrorStatus("Worker returned an empty login status.");
}
@@ -40,7 +52,36 @@ public sealed class WorkerMaxBridgeClient(
public async Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken 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)
@@ -69,11 +110,14 @@ public sealed class WorkerMaxBridgeClient(
?? 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(
false,
"Chat deletion is disabled because MAX does not remove chats from the web client."));
return await SendAsync<MaxActionResult>(
HttpMethod.Post,
"/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)
@@ -88,6 +132,20 @@ public sealed class WorkerMaxBridgeClient(
?? new MaxSendResult(false, null, "Worker returned an empty attachment result.");
}
public async Task<MaxSendResult> ForwardMessageAsync(
string externalChatId,
string sourceExternalChatId,
string sourceExternalMessageId,
CancellationToken cancellationToken)
{
return await SendAsync<MaxSendResult>(
HttpMethod.Post,
"/message/forward",
new { externalChatId, sourceExternalChatId, sourceExternalMessageId },
cancellationToken)
?? new MaxSendResult(false, null, "Worker returned an empty forward result.");
}
public async Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
{
return await SendAsync<MaxActionResult>(
@@ -139,7 +197,9 @@ public sealed class WorkerMaxBridgeClient(
{
httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
var path = $"media/fetch?url={Uri.EscapeDataString(remoteUrl)}";
var response = await httpClient.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Get, path);
AddAccountHeader(request);
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
@@ -160,12 +220,32 @@ 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)
{
try
{
httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
using var request = new HttpRequestMessage(method, path.TrimStart('/'));
AddAccountHeader(request);
if (body is not null)
{
request.Content = JsonContent.Create(body, options: JsonOptions);
@@ -188,6 +268,15 @@ public sealed class WorkerMaxBridgeClient(
}
}
private void AddAccountHeader(HttpRequestMessage request)
{
if (currentUser.UserId is not { } userId)
{
throw new InvalidOperationException("A QMAX user context is required for a PyMax request.");
}
request.Headers.Add("X-QMax-Account-Id", userId.ToString("N"));
}
private static MaxBridgeStatus ErrorStatus(string error)
{
return new MaxBridgeStatus("Worker", false, "Unavailable", "WorkerUnavailable", null, null, error, DateTimeOffset.UtcNow);
@@ -0,0 +1,303 @@
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 declaredContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
var resolvedKind = preferredKind ?? GuessKind(declaredContentType, extension);
var resolvedContentType = NormalizeContentType(declaredContentType, safeOriginalName, resolvedKind);
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 NormalizeContentType(string? contentType, string? fileName, AttachmentKind kind)
{
var declared = contentType?.Trim();
if (!string.IsNullOrWhiteSpace(declared) &&
!declared.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase) &&
!declared.Equals("binary/octet-stream", StringComparison.OrdinalIgnoreCase))
{
return declared;
}
var extension = Path.GetExtension(fileName ?? "").ToLowerInvariant();
var inferred = extension switch
{
".m4a" => "audio/mp4",
".ogg" or ".oga" => "audio/ogg",
".opus" => "audio/opus",
".aac" => "audio/aac",
".mp3" => "audio/mpeg",
".wav" => "audio/wav",
".flac" => "audio/flac",
_ => null
};
return inferred ?? (kind == AttachmentKind.VoiceNote ? "audio/ogg" : declared ?? "application/octet-stream");
}
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"
};
}
}
+100
View File
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using QMax.Api.Configuration;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Auth;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max;
@@ -90,6 +91,7 @@ builder.Services.AddControllers().AddJsonOptions(options =>
});
builder.Services.AddSignalR();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<ICurrentUserAccessor, CurrentUserAccessor>();
builder.Services.AddSingleton<ITokenService, TokenService>();
builder.Services.AddScoped<ChatProjectionService>();
builder.Services.AddScoped<IPushNotificationService, FirebasePushNotificationService>();
@@ -117,6 +119,15 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
app.UseCors();
app.UseAuthentication();
app.Use(async (context, next) =>
{
var currentUser = context.RequestServices.GetRequiredService<ICurrentUserAccessor>();
var userId = context.User.Identity?.IsAuthenticated == true ? context.User.GetUserId() : (Guid?)null;
using (currentUser.Push(userId))
{
await next(context);
}
});
app.UseAuthorization();
app.MapControllers();
app.MapHub<QMaxHub>("/hubs/qmax");
@@ -127,8 +138,12 @@ using (var scope = app.Services.CreateScope())
Directory.CreateDirectory(options.StoragePath);
Directory.CreateDirectory(options.ReleasesPath);
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var currentUser = scope.ServiceProvider.GetRequiredService<ICurrentUserAccessor>();
using (currentUser.Push(null, bypassTenantFilter: true))
{
await db.Database.EnsureCreatedAsync();
await EnsureCompatibilitySchemaAsync(db);
}
}
app.Run();
@@ -152,6 +167,34 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
}
}
if (!chatColumns.Contains("UserId"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN UserId TEXT NULL;");
var legacyUserId = await db.Users.Select(x => x.Id).FirstOrDefaultAsync();
if (legacyUserId != Guid.Empty)
{
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE Chats SET UserId = {legacyUserId} WHERE UserId IS NULL;");
}
}
var maxStateColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using (var command = connection.CreateCommand())
{
command.CommandText = "PRAGMA table_info(MaxAccountStates);";
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync()) maxStateColumns.Add(reader.GetString(1));
}
if (maxStateColumns.Count > 0 && !maxStateColumns.Contains("UserId"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE MaxAccountStates ADD COLUMN UserId TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';");
var legacyUserId = await db.Users.Select(x => x.Id).FirstOrDefaultAsync();
if (legacyUserId != Guid.Empty)
{
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE MaxAccountStates SET UserId = {legacyUserId} WHERE UserId = '00000000-0000-0000-0000-000000000000';");
}
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MaxAccountStates_UserId ON MaxAccountStates (UserId);");
}
if (!chatColumns.Contains("WebUrl"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN WebUrl TEXT;");
@@ -192,6 +235,27 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN PendingMaxActionError TEXT;");
}
var messageColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using (var command = connection.CreateCommand())
{
command.CommandText = "PRAGMA table_info(Messages);";
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
messageColumns.Add(reader.GetString(1));
}
}
if (!messageColumns.Contains("ForwardedFromExternalChatId"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Messages ADD COLUMN ForwardedFromExternalChatId TEXT;");
}
if (!messageColumns.Contains("ForwardedFromExternalMessageId"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Messages ADD COLUMN ForwardedFromExternalMessageId TEXT;");
}
var attachmentColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using (var command = connection.CreateCommand())
{
@@ -218,6 +282,10 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
await QMaxDatabaseCleanup.MergeOutgoingRemoteAttachmentEchoesAsync(db);
await QMaxDatabaseCleanup.ClearUnreadCountsForLatestOutgoingChatsAsync(db);
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_MessageAttachments_MessageId_ExternalId;");
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_Chats_ExternalId;");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_Chats_UserId_ExternalId ON Chats (UserId, ExternalId);");
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_Users_PhoneNumber;");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_Users_PhoneNumber ON Users (PhoneNumber) WHERE PhoneNumber IS NOT NULL AND PhoneNumber <> '';");
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageAttachments_MessageId_ExternalId
ON MessageAttachments (MessageId, ExternalId)
@@ -255,6 +323,22 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
CONSTRAINT FK_MessageReactions_Messages_MessageId FOREIGN KEY (MessageId) REFERENCES Messages (Id) ON DELETE CASCADE
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS MaxLoginChallenges (
Id TEXT NOT NULL CONSTRAINT PK_MaxLoginChallenges PRIMARY KEY,
UserId TEXT NOT NULL,
SecretHash TEXT NOT NULL,
DeviceName TEXT NOT NULL,
CreatedAt TEXT NOT NULL,
ExpiresAt TEXT NOT NULL,
CompletedAt TEXT NULL,
FailedAttempts INTEGER NOT NULL DEFAULT 0,
CONSTRAINT FK_MaxLoginChallenges_Users_UserId FOREIGN KEY (UserId) REFERENCES Users (Id) ON DELETE CASCADE
);
""");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MaxLoginChallenges_SecretHash ON MaxLoginChallenges (SecretHash);");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_MaxLoginChallenges_UserId ON MaxLoginChallenges (UserId);");
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageReactions_MessageId_ActorKey
ON MessageReactions (MessageId, ActorKey);
@@ -323,6 +407,22 @@ static async Task RemoveGenericMediaLabelsAsync(QMaxDbContext db)
var attachments = await db.MessageAttachments.ToListAsync();
foreach (var attachment in attachments)
{
if (attachment.Kind == AttachmentKind.File)
{
var inferredKind = AttachmentStorageService.GuessKind(
attachment.ContentType,
Path.GetExtension(attachment.OriginalFileName));
if (inferredKind != AttachmentKind.File)
{
attachment.Kind = inferredKind;
}
}
attachment.ContentType = AttachmentStorageService.NormalizeContentType(
attachment.ContentType,
attachment.OriginalFileName,
attachment.Kind);
var normalizedFileName = AttachmentStorageService.NormalizeRemoteFileName(
attachment.OriginalFileName,
attachment.ContentType,
+236 -23
View File
@@ -1,5 +1,8 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using QMax.Api.Contracts;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs;
@@ -7,6 +10,7 @@ using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage;
using System.Collections.Concurrent;
using System.Text;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services;
@@ -14,6 +18,7 @@ public sealed class MaxBridgeSyncService(
IServiceScopeFactory scopeFactory,
IMaxBridgeClient maxBridgeClient,
IHubContext<QMaxHub> hubContext,
ICurrentUserAccessor currentUser,
ILogger<MaxBridgeSyncService> logger)
{
private const string PreviewExternalIdPrefix = "preview:";
@@ -29,10 +34,41 @@ public sealed class MaxBridgeSyncService(
catch (Exception ex)
{
logger.LogWarning(ex, "MAX sync failed.");
await PublishCurrentMaxStatusAsync(cancellationToken);
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 userId = currentUser.UserId ?? throw new InvalidOperationException("MAX sync requires a user context.");
var state = await db.MaxAccountStates.FirstOrDefaultAsync(cancellationToken);
if (state is null)
{
state = new MaxAccountState { UserId = userId };
db.MaxAccountStates.Add(state);
}
state.PhoneNumber = await db.Users.Where(x => x.Id == userId).Select(x => x.PhoneNumber).FirstOrDefaultAsync(cancellationToken) ?? "";
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.User(userId.ToString()).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)
{
if (string.IsNullOrWhiteSpace(externalChatId))
@@ -59,6 +95,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(
IReadOnlyList<MaxChatUpdate> updates,
bool incrementUnread,
@@ -363,7 +409,13 @@ public sealed class MaxBridgeSyncService(
foreach (var incomingAttachment in DistinctAttachments(incoming.Attachments ?? []))
{
var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken);
var attachment = await CreateAttachmentAsync(
db,
incoming,
incomingAttachment,
maxBridgeClient,
storage,
cancellationToken);
if (attachment is not null)
{
message.Attachments.Add(attachment);
@@ -421,7 +473,7 @@ public sealed class MaxBridgeSyncService(
}
}
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User((currentUser.UserId ?? Guid.Empty).ToString()).SendAsync("ChatListInvalidated", cancellationToken);
}
foreach (var request in genericMediaHistoryRequests
@@ -978,6 +1030,35 @@ public sealed class MaxBridgeSyncService(
foreach (var incomingAttachment in incomingAttachments)
{
var existingAttachment = FindMatchingAttachment(knownAttachments, incomingAttachment);
if (existingAttachment is not null)
{
if (IsUncachedRemotePlaceholder(existingAttachment) &&
RequiresCachedRemoteMedia(existingAttachment.Kind))
{
var hydrated = await TryHydrateRemoteAttachmentAsync(
existingAttachment,
incomingAttachment,
maxBridgeClient,
storage,
cancellationToken);
if (!hydrated)
{
hydrated = await TryHydrateFromCachedOutgoingAsync(
db,
existingAttachment,
incoming,
incomingAttachment,
storage,
cancellationToken);
}
changed |= hydrated;
}
continue;
}
if (TryMergeOutgoingRemoteEchoIntoLocalAttachment(
message,
incoming,
@@ -989,20 +1070,13 @@ public sealed class MaxBridgeSyncService(
continue;
}
var existingAttachment = FindMatchingAttachment(knownAttachments, incomingAttachment);
if (existingAttachment is not null)
{
if (IsUncachedRemotePlaceholder(existingAttachment) &&
RequiresCachedRemoteMedia(existingAttachment.Kind) &&
await TryHydrateRemoteAttachmentAsync(existingAttachment, incomingAttachment, maxBridgeClient, storage, cancellationToken))
{
changed = true;
}
continue;
}
var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken);
var attachment = await CreateAttachmentAsync(
db,
incoming,
incomingAttachment,
maxBridgeClient,
storage,
cancellationToken);
if (attachment is null)
{
continue;
@@ -1102,6 +1176,8 @@ public sealed class MaxBridgeSyncService(
}
private static async Task<MessageAttachment?> CreateAttachmentAsync(
QMaxDbContext db,
MaxMessageUpdate incomingMessage,
MaxAttachmentUpdate incoming,
IMaxBridgeClient maxBridgeClient,
IAttachmentStorageService storage,
@@ -1175,20 +1251,62 @@ public sealed class MaxBridgeSyncService(
// Non-visual files can stay as deferred downloads; inline media must be cached before it is shown.
}
var cachedAttachment = CreateRemotePlaceholder(incoming, kind, remoteUrl);
if (await TryHydrateFromCachedOutgoingAsync(
db,
cachedAttachment,
incomingMessage,
incoming,
storage,
cancellationToken))
{
return cachedAttachment;
}
if (RequiresCachedRemoteMedia(kind))
{
return null;
}
return cachedAttachment;
}
var placeholder = CreateRemotePlaceholder(incoming, kind, remoteUrl);
if (await TryHydrateFromCachedOutgoingAsync(
db,
placeholder,
incomingMessage,
incoming,
storage,
cancellationToken))
{
return placeholder;
}
return placeholder;
}
private static MessageAttachment CreateRemotePlaceholder(
MaxAttachmentUpdate incoming,
AttachmentKind? kind,
string? remoteUrl)
{
var resolvedKind = kind ?? AttachmentKind.File;
var originalFileName = AttachmentStorageService.NormalizeRemoteFileName(
incoming.FileName,
incoming.ContentType,
resolvedKind);
return new MessageAttachment
{
ExternalId = incoming.ExternalId,
OriginalFileName = AttachmentStorageService.NormalizeRemoteFileName(incoming.FileName, incoming.ContentType, kind),
OriginalFileName = originalFileName,
StorageFileName = $"remote-{Guid.NewGuid():N}",
ContentType = string.IsNullOrWhiteSpace(incoming.ContentType) ? "application/octet-stream" : incoming.ContentType,
ContentType = AttachmentStorageService.NormalizeContentType(
incoming.ContentType,
originalFileName,
resolvedKind),
FileSizeBytes = incoming.FileSizeBytes ?? 0,
Kind = kind ?? AttachmentKind.File,
Kind = resolvedKind,
SortOrder = incoming.SortOrder,
RemoteUrl = remoteUrl
};
@@ -1235,11 +1353,93 @@ public sealed class MaxBridgeSyncService(
private static bool IsUncachedRemotePlaceholder(MessageAttachment attachment)
{
return !string.IsNullOrWhiteSpace(attachment.RemoteUrl) &&
attachment.StorageFileName.StartsWith("remote-", StringComparison.Ordinal) &&
return attachment.StorageFileName.StartsWith("remote-", StringComparison.Ordinal) &&
(attachment.FileSizeBytes <= 0 || string.IsNullOrWhiteSpace(attachment.Sha256));
}
private static async Task<bool> TryHydrateFromCachedOutgoingAsync(
QMaxDbContext db,
MessageAttachment attachment,
MaxMessageUpdate incomingMessage,
MaxAttachmentUpdate incomingAttachment,
IAttachmentStorageService storage,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(incomingMessage.ExternalId))
{
return false;
}
var kind = ResolveKind(incomingAttachment) ?? attachment.Kind;
var expectedSize = incomingAttachment.FileSizeBytes.GetValueOrDefault();
var messages = await db.Messages
.IgnoreQueryFilters()
.AsNoTracking()
.Include(x => x.Attachments)
.Where(x =>
x.ExternalId == incomingMessage.ExternalId &&
x.Direction == MessageDirection.Outgoing &&
x.DeletedAt == null)
.ToListAsync(cancellationToken);
var cached = messages
.SelectMany(x => x.Attachments)
.Where(x =>
x.Kind == kind &&
x.SortOrder == incomingAttachment.SortOrder &&
(string.IsNullOrWhiteSpace(incomingAttachment.ExternalId) ||
x.ExternalId == incomingAttachment.ExternalId) &&
(expectedSize <= 0 || x.FileSizeBytes == expectedSize) &&
x.FileSizeBytes > 0 &&
!string.IsNullOrWhiteSpace(x.Sha256) &&
!x.StorageFileName.StartsWith("remote-", StringComparison.Ordinal))
.Select(x => new { Attachment = x, Path = storage.GetPath(x.StorageFileName) })
.FirstOrDefault(x =>
File.Exists(x.Path) &&
new FileInfo(x.Path).Length == x.Attachment.FileSizeBytes);
if (cached is null)
{
return false;
}
try
{
await using var source = new FileStream(
cached.Path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
128 * 1024,
useAsync: true);
var stored = await storage.SaveRemoteAsync(
incomingAttachment.FileName,
cached.Attachment.ContentType,
source,
expectedSize > 0 ? expectedSize : cached.Attachment.FileSizeBytes,
kind,
cancellationToken);
attachment.ExternalId = string.IsNullOrWhiteSpace(incomingAttachment.ExternalId)
? attachment.ExternalId
: incomingAttachment.ExternalId;
attachment.OriginalFileName = stored.OriginalFileName;
attachment.StorageFileName = stored.StorageFileName;
attachment.ContentType = stored.ContentType;
attachment.FileSizeBytes = stored.FileSizeBytes;
attachment.Sha256 = stored.Sha256;
attachment.Kind = stored.Kind;
attachment.SortOrder = incomingAttachment.SortOrder;
attachment.RemoteUrl = string.IsNullOrWhiteSpace(incomingAttachment.RemoteUrl)
? attachment.RemoteUrl
: incomingAttachment.RemoteUrl;
return true;
}
catch (IOException)
{
return false;
}
}
private static async Task<bool> TryHydrateRemoteAttachmentAsync(
MessageAttachment attachment,
MaxAttachmentUpdate incoming,
@@ -1295,10 +1495,18 @@ public sealed class MaxBridgeSyncService(
{
if (!string.IsNullOrWhiteSpace(attachment.Kind) &&
Enum.TryParse<AttachmentKind>(attachment.Kind, ignoreCase: true, out var parsed))
{
if (parsed != AttachmentKind.File)
{
return parsed;
}
var inferred = AttachmentStorageService.GuessKind(
attachment.ContentType,
Path.GetExtension(attachment.FileName));
return inferred == AttachmentKind.File ? parsed : inferred;
}
var extension = Path.GetExtension(attachment.FileName);
return AttachmentStorageService.GuessKind(attachment.ContentType, extension);
}
@@ -1357,10 +1565,10 @@ public sealed class MaxBridgeSyncService(
return await deletedChats.FirstOrDefaultAsync(x => x.AvatarUrl == update.AvatarUrl, cancellationToken);
}
var titleMatches = await deletedChats
var titleMatches = (await deletedChats.ToListAsync(cancellationToken))
.OrderByDescending(x => x.DeletedAt)
.Take(2)
.ToListAsync(cancellationToken);
.ToList();
return titleMatches.Count == 1 ? titleMatches[0] : null;
}
@@ -1387,6 +1595,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(
MessageDirection Direction,
string? Text,
+17 -4
View File
@@ -5,6 +5,7 @@ using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services;
@@ -14,6 +15,7 @@ public sealed class MaxOutboxService(
IAttachmentStorageService storage,
ChatProjectionService projection,
IHubContext<QMaxHub> hubContext,
ICurrentUserAccessor currentUser,
ILogger<MaxOutboxService> logger)
{
private static readonly TimeSpan InitialChatActionRetryDelay = TimeSpan.FromMinutes(10);
@@ -125,9 +127,10 @@ public sealed class MaxOutboxService(
chat,
(externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken),
cancellationToken),
ChatPendingMaxAction.DeleteChat => new MaxActionResult(
false,
"Chat deletion is disabled because MAX does not remove chats from the web client."),
ChatPendingMaxAction.DeleteChat => await ApplyMaxChatActionAsync(
chat,
(externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken),
cancellationToken),
_ => new MaxActionResult(false, $"Unsupported chat action: {pendingAction.Value}.")
};
}
@@ -275,6 +278,16 @@ public sealed class MaxOutboxService(
{
var externalChatId = chat.ExternalId!;
var chatUrl = chat.WebUrl;
if (!string.IsNullOrWhiteSpace(message.ForwardedFromExternalChatId) &&
!string.IsNullOrWhiteSpace(message.ForwardedFromExternalMessageId))
{
return await maxBridge.ForwardMessageAsync(
externalChatId,
message.ForwardedFromExternalChatId,
message.ForwardedFromExternalMessageId,
cancellationToken);
}
var attachments = message.Attachments.OrderBy(attachment => attachment.SortOrder).ToArray();
if (attachments.Length == 0)
{
@@ -337,6 +350,6 @@ public sealed class MaxOutboxService(
"MessageUpdated",
projection.ToDto(updated),
cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User((currentUser.UserId ?? Guid.Empty).ToString()).SendAsync("ChatListInvalidated", cancellationToken);
}
}
+25 -1
View File
@@ -1,11 +1,15 @@
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services;
public sealed class MaxOutboxWorker(
IServiceScopeFactory scopeFactory,
IOptions<QMaxOptions> options,
ICurrentUserAccessor currentUser,
ILogger<MaxOutboxWorker> logger) : BackgroundService
{
private readonly QMaxOptions _options = options.Value;
@@ -48,10 +52,30 @@ public sealed class MaxOutboxWorker(
try
{
await using var scope = scopeFactory.CreateAsyncScope();
Guid[] userIds;
await using (var discoveryScope = scopeFactory.CreateAsyncScope())
using (currentUser.Push(null, bypassTenantFilter: true))
{
var db = discoveryScope.ServiceProvider.GetRequiredService<QMaxDbContext>();
userIds = await db.MaxAccountStates
.Where(x => x.IsAuthorized)
.Select(x => x.UserId)
.ToArrayAsync(stoppingToken);
if (userIds.Length == 0 && !await db.MaxAccountStates.AnyAsync(stoppingToken))
{
userIds = await db.Users.Select(x => x.Id).ToArrayAsync(stoppingToken);
}
}
foreach (var userId in userIds)
{
using (currentUser.Push(userId))
await using (var scope = scopeFactory.CreateAsyncScope())
{
var outbox = scope.ServiceProvider.GetRequiredService<MaxOutboxService>();
await process(outbox, stoppingToken);
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "QMAX {WorkerName} tick failed.", workerName);
+25
View File
@@ -1,11 +1,16 @@
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services;
public sealed class MaxSyncWorker(
IOptions<QMaxOptions> options,
MaxBridgeSyncService syncService,
IServiceScopeFactory scopeFactory,
ICurrentUserAccessor currentUser,
ILogger<MaxSyncWorker> logger) : BackgroundService
{
private readonly QMaxOptions _options = options.Value;
@@ -18,9 +23,29 @@ public sealed class MaxSyncWorker(
while (!stoppingToken.IsCancellationRequested)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
Guid[] userIds;
using (currentUser.Push(null, bypassTenantFilter: true))
{
userIds = await db.MaxAccountStates
.Where(x => x.IsAuthorized)
.Select(x => x.UserId)
.ToArrayAsync(stoppingToken);
if (userIds.Length == 0 && !await db.MaxAccountStates.AnyAsync(stoppingToken))
{
userIds = await db.Users.Select(x => x.Id).ToArrayAsync(stoppingToken);
}
}
foreach (var userId in userIds)
{
using (currentUser.Push(userId))
{
await syncService.SyncOnceAsync(stoppingToken);
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "QMAX background sync tick failed.");
@@ -2,8 +2,15 @@ namespace QMax.Api.Services;
public static class MessageTextSanitizer
{
private const string IncomingCallText = "\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a";
public static string? CleanChatPreview(string? value)
{
if (IsCallMediaLabel(value))
{
return IncomingCallText;
}
if (IsGenericMediaLabel(value))
{
return "\u041c\u0435\u0434\u0438\u0430";
@@ -16,6 +23,11 @@ public static class MessageTextSanitizer
public static string? CleanMessageText(string? value, bool hasAttachments)
{
if (IsCallMediaLabel(value))
{
return IncomingCallText;
}
if (hasAttachments && IsGenericAttachmentLabel(value))
{
return null;
@@ -46,7 +58,9 @@ public static class MessageTextSanitizer
"\u0430\u0443\u0434\u0438\u043e" => true,
"audio" => true,
"\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435" => true,
"\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435" => true,
"voice" => true,
"voice message" => true,
"\u0441\u0442\u0438\u043a\u0435\u0440" => true,
"sticker" => true,
"\u044d\u043c\u043e\u0434\u0437\u0438" => true,
@@ -58,6 +72,13 @@ public static class MessageTextSanitizer
};
}
public static bool IsCallMediaLabel(string? value)
{
var text = value?.Trim();
return string.Equals(text, "call", StringComparison.OrdinalIgnoreCase) ||
text?.StartsWith("call-", StringComparison.OrdinalIgnoreCase) == true;
}
public static bool IsGenericAttachmentLabel(string? value)
{
if (IsGenericMediaLabel(value))
+1 -1
View File
@@ -16,7 +16,7 @@
"PairingCode": "",
"MaxPhoneNumber": "",
"MaxMode": "Worker",
"MaxWorkerBaseUrl": "http://qmax-max-worker:3001",
"MaxWorkerBaseUrl": "http://qmax-pymax-worker:3002",
"MaxUserDataPath": "data/max-profile",
"MaxHeadless": true,
"MaxPollIntervalSeconds": 6,
+336 -14
View File
@@ -112,7 +112,44 @@ public sealed class ApiSmokeTests : IDisposable
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
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]
@@ -328,7 +365,7 @@ public sealed class ApiSmokeTests : IDisposable
var stored = await storage.SaveRemoteAsync(
"voice.m4a",
"audio/mp4",
"application/octet-stream",
stream,
bytes.Length,
null,
@@ -339,6 +376,7 @@ public sealed class ApiSmokeTests : IDisposable
Assert.False(File.Exists(finalPath + ".part"));
Assert.Equal(bytes.Length, stored.FileSizeBytes);
Assert.Equal(AttachmentKind.VoiceNote, stored.Kind);
Assert.Equal("audio/mp4", stored.ContentType);
Assert.Equal(bytes, await File.ReadAllBytesAsync(finalPath));
Directory.Delete(storagePath, recursive: true);
@@ -442,6 +480,194 @@ public sealed class ApiSmokeTests : IDisposable
Assert.Equal(bridge.RemoteUrl, storedAttachment.RemoteUrl);
}
[Fact]
public async Task SyncMergesGenericFileEchoIntoOutgoingVoiceNote()
{
var voiceBytes = new byte[] { 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x4D, 0x34, 0x41, 0x20 };
var bridge = new OutgoingAttachmentEchoMaxBridgeClient(
AttachmentKind.File,
"remote-voice.m4a",
"application/octet-stream",
voiceBytes);
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.AddSingleton<IMaxBridgeClient>(bridge);
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var chat = await CreateDirectChatAsync(client, bridge.ChatExternalId, "Outgoing Voice Echo");
using var form = new MultipartFormDataContent();
using var fileContent = new ByteArrayContent(voiceBytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("audio/mp4");
form.Add(fileContent, "file", "local-voice.m4a");
var upload = await client.PostAsync($"/api/chats/{chat.Id}/attachments", form);
await AssertStatusAsync(HttpStatusCode.OK, upload);
var sent = await upload.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(sent);
Assert.True(await ProcessOutboxAsync(factory) >= 1);
var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync);
var messages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
var echoed = Assert.Single(messages!);
Assert.Equal(sent!.Id, echoed.Id);
var attachment = Assert.Single(echoed.Attachments);
Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind);
Assert.Equal("audio/mp4", attachment.ContentType);
Assert.Equal(voiceBytes.Length, attachment.FileSizeBytes);
}
[Fact]
public async Task SyncHydratesIncomingVoiceWithoutRemoteUrlFromMatchingOutgoingCache()
{
var voiceBytes = new byte[] { 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x4D, 0x34, 0x41, 0x20 };
var bridge = new OutgoingAttachmentEchoMaxBridgeClient(
AttachmentKind.File,
"received-voice.m4a",
"application/octet-stream",
voiceBytes,
isOutgoing: false,
includeRemoteUrl: false,
allowMediaDownload: false);
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.AddSingleton<IMaxBridgeClient>(bridge);
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var target = await CreateDirectChatAsync(client, bridge.ChatExternalId, "Incoming Voice");
var firstSync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, firstSync);
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var placeholder = await db.MessageAttachments
.SingleAsync(x => x.ExternalId == bridge.RemoteAttachmentExternalId);
Assert.StartsWith("remote-", placeholder.StorageFileName);
Assert.Null(placeholder.Sha256);
}
var source = await CreateDirectChatAsync(client, $"voice-source-{Guid.NewGuid():N}", "Voice Source");
using var form = new MultipartFormDataContent();
using var fileContent = new ByteArrayContent(voiceBytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("audio/mp4");
form.Add(fileContent, "file", "sent-voice.m4a");
var upload = await client.PostAsync($"/api/chats/{source.Id}/attachments", form);
await AssertStatusAsync(HttpStatusCode.OK, upload);
var sourceMessage = await upload.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(sourceMessage);
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedSource = await db.Messages
.Include(x => x.Attachments)
.SingleAsync(x => x.Id == sourceMessage!.Id);
storedSource.ExternalId = bridge.MessageExternalId;
storedSource.DeliveryState = MessageDeliveryState.Sent;
Assert.Single(storedSource.Attachments).ExternalId = bridge.RemoteAttachmentExternalId;
await db.SaveChangesAsync();
}
var secondSync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, secondSync);
var messages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{target.Id}/messages", JsonOptions);
var received = Assert.Single(messages!);
Assert.Equal(MessageDirection.Incoming, received.Direction);
var attachment = Assert.Single(received.Attachments);
Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind);
Assert.Equal("audio/mp4", attachment.ContentType);
Assert.Equal(voiceBytes.Length, attachment.FileSizeBytes);
var download = await client.GetAsync(attachment.DownloadPath);
await AssertStatusAsync(HttpStatusCode.OK, download);
Assert.Equal(voiceBytes, await download.Content.ReadAsByteArrayAsync());
}
[Fact]
public async Task ForwardedVoiceUsesNativeForwardAndDoesNotDuplicateOnEcho()
{
var voiceBytes = new byte[] { 0x4F, 0x67, 0x67, 0x53, 0x00, 0x02, 0x56, 0x4F, 0x49, 0x43, 0x45 };
var bridge = new OutgoingAttachmentEchoMaxBridgeClient(
AttachmentKind.VoiceNote,
"max-voice.ogg",
"audio/ogg",
voiceBytes);
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.AddSingleton<IMaxBridgeClient>(bridge);
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var source = await CreateDirectChatAsync(client, $"voice-source-{Guid.NewGuid():N}", "Voice Source");
var target = await CreateDirectChatAsync(client, bridge.ChatExternalId, "Voice Target");
using var form = new MultipartFormDataContent();
using var fileContent = new ByteArrayContent(voiceBytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("audio/ogg");
form.Add(fileContent, "file", "source-voice.ogg");
var upload = await client.PostAsync($"/api/chats/{source.Id}/attachments", form);
await AssertStatusAsync(HttpStatusCode.OK, upload);
var sourceMessage = await upload.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(sourceMessage);
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedSource = await db.Messages.SingleAsync(x => x.Id == sourceMessage!.Id);
storedSource.ExternalId = $"source-voice-{Guid.NewGuid():N}";
storedSource.DeliveryState = MessageDeliveryState.Sent;
await db.SaveChangesAsync();
}
var forwardResponse = await client.PostAsJsonAsync(
$"/api/chats/{source.Id}/messages/{sourceMessage!.Id}/forward",
new ForwardMessageRequest(target.Id));
await AssertStatusAsync(HttpStatusCode.OK, forwardResponse);
var pendingForward = await forwardResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(pendingForward);
Assert.Equal(MessageDeliveryState.Sending, pendingForward!.DeliveryState);
Assert.Equal(AttachmentKind.VoiceNote, Assert.Single(pendingForward.Attachments).Kind);
Assert.True(await ProcessOutboxAsync(factory) >= 1);
var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync);
var targetMessages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{target.Id}/messages", JsonOptions);
var forwarded = Assert.Single(targetMessages!);
Assert.Equal(pendingForward.Id, forwarded.Id);
Assert.Equal(bridge.MessageExternalId, forwarded.ExternalId);
Assert.Equal(MessageDeliveryState.Sent, forwarded.DeliveryState);
var attachment = Assert.Single(forwarded.Attachments);
Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind);
var download = await client.GetAsync(attachment.DownloadPath);
await AssertStatusAsync(HttpStatusCode.OK, download);
Assert.Equal(voiceBytes, await download.Content.ReadAsByteArrayAsync());
}
[Fact]
public async Task ForwardMessageFlowCopiesTextAndAttachments()
{
@@ -1104,8 +1330,11 @@ public sealed class ApiSmokeTests : IDisposable
JsonOptions);
await AssertStatusAsync(HttpStatusCode.OK, messageResponse);
Assert.Equal(2, await ProcessOutboxAsync(factory));
Assert.Equal(["send:text", "clear"], bridge.OperationOrder);
Assert.True(await ProcessOutboxAsync(factory) >= 2);
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]
@@ -1447,6 +1676,21 @@ public sealed class ApiSmokeTests : IDisposable
Assert.Equal("hello ????", MessageTextSanitizer.CleanChatPreview("hello ????"));
}
[Theory]
[InlineData("call-start")]
[InlineData("CALL-MISSED")]
[InlineData(" call-ended ")]
[InlineData("call")]
public void MessageTextSanitizerLabelsCallMediaAsIncomingCall(string value)
{
const string expected = "\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a";
Assert.True(MessageTextSanitizer.IsCallMediaLabel(value));
Assert.Equal(expected, MessageTextSanitizer.CleanChatPreview(value));
Assert.Equal(expected, MessageTextSanitizer.CleanMessageText(value, hasAttachments: false));
Assert.Equal(expected, MessageTextSanitizer.CleanMessageText(value, hasAttachments: true));
}
[Fact]
public async Task SyncDoesNotPushListPreviewEchoOfOwnOutgoingMessage()
{
@@ -1731,6 +1975,8 @@ public sealed class ApiSmokeTests : IDisposable
.Options;
var messageId = Guid.NewGuid();
var localAttachmentId = Guid.NewGuid();
var voiceMessageId = Guid.NewGuid();
var localVoiceAttachmentId = Guid.NewGuid();
try
{
@@ -1770,7 +2016,38 @@ public sealed class ApiSmokeTests : IDisposable
Kind = AttachmentKind.Image,
SortOrder = 0
});
db.Messages.Add(message);
var voiceMessage = new Message
{
Id = voiceMessageId,
Chat = chat,
ExternalId = "domhist:voice",
Direction = MessageDirection.Outgoing,
DeliveryState = MessageDeliveryState.Sent,
SentAt = DateTimeOffset.UtcNow.AddSeconds(1)
};
voiceMessage.Attachments.Add(new MessageAttachment
{
Id = localVoiceAttachmentId,
OriginalFileName = "local-voice.m4a",
StorageFileName = $"{Guid.NewGuid():N}.m4a",
ContentType = "audio/mp4",
FileSizeBytes = 35_985,
Sha256 = "same-voice-sha",
Kind = AttachmentKind.VoiceNote,
SortOrder = 0
});
voiceMessage.Attachments.Add(new MessageAttachment
{
OriginalFileName = "remote-voice.m4a",
StorageFileName = $"{Guid.NewGuid():N}.m4a",
ExternalId = "dommedia:voice",
ContentType = "application/octet-stream",
FileSizeBytes = 35_985,
Sha256 = "",
Kind = AttachmentKind.File,
SortOrder = 0
});
db.Messages.AddRange(message, voiceMessage);
await db.SaveChangesAsync();
}
@@ -1785,6 +2062,12 @@ public sealed class ApiSmokeTests : IDisposable
Assert.Equal("local-photo.png", attachment.OriginalFileName);
Assert.Equal("dommedia:remote", attachment.ExternalId);
Assert.Equal("https://max.test/media/photo.jpg", attachment.RemoteUrl);
var voiceAttachment = await verifyDb.MessageAttachments.SingleAsync(x => x.MessageId == voiceMessageId);
Assert.Equal(localVoiceAttachmentId, voiceAttachment.Id);
Assert.Equal(AttachmentKind.VoiceNote, voiceAttachment.Kind);
Assert.Equal("audio/mp4", voiceAttachment.ContentType);
Assert.Equal("dommedia:voice", voiceAttachment.ExternalId);
}
finally
{
@@ -2469,10 +2752,35 @@ public sealed class ApiSmokeTests : IDisposable
private sealed class OutgoingAttachmentEchoMaxBridgeClient : IMaxBridgeClient
{
public readonly string ChatExternalId = $"mock-outgoing-attachment-echo-{Guid.NewGuid():N}";
public readonly string MessageExternalId = $"outgoing-photo-{Guid.NewGuid():N}";
public readonly string MessageExternalId = $"outgoing-media-{Guid.NewGuid():N}";
public readonly string RemoteAttachmentExternalId = $"dommedia:{Guid.NewGuid():N}";
public readonly string RemoteUrl = $"https://max.test/media/{Guid.NewGuid():N}.jpg";
private static readonly byte[] RemoteBytes = [0xFF, 0xD8, 0x52, 0x45, 0x4D, 0x4F, 0x54, 0x45, 0xFF, 0xD9];
public readonly string? RemoteUrl;
private readonly AttachmentKind _kind;
private readonly string _fileName;
private readonly string _contentType;
private readonly byte[] _remoteBytes;
private readonly bool _isOutgoing;
private readonly bool _allowMediaDownload;
public OutgoingAttachmentEchoMaxBridgeClient(
AttachmentKind kind = AttachmentKind.Image,
string fileName = "max-image-1.jpg",
string contentType = "image/jpeg",
byte[]? remoteBytes = null,
bool isOutgoing = true,
bool includeRemoteUrl = true,
bool allowMediaDownload = true)
{
_kind = kind;
_fileName = fileName;
_contentType = contentType;
_remoteBytes = remoteBytes ?? [0xFF, 0xD8, 0x52, 0x45, 0x4D, 0x4F, 0x54, 0x45, 0xFF, 0xD9];
_isOutgoing = isOutgoing;
_allowMediaDownload = allowMediaDownload;
RemoteUrl = includeRemoteUrl
? $"https://max.test/media/{Guid.NewGuid():N}{Path.GetExtension(fileName)}"
: null;
}
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
{
@@ -2509,17 +2817,17 @@ public sealed class ApiSmokeTests : IDisposable
MessageExternalId,
"self",
"You",
true,
_isOutgoing,
"",
sentAt,
[
new MaxAttachmentUpdate(
RemoteAttachmentExternalId,
"max-image-1.jpg",
"image/jpeg",
RemoteBytes.Length,
_fileName,
_contentType,
_remoteBytes.Length,
RemoteUrl,
nameof(AttachmentKind.Image),
_kind.ToString(),
0)
])
],
@@ -2564,6 +2872,15 @@ public sealed class ApiSmokeTests : IDisposable
return Task.FromResult(new MaxSendResult(true, MessageExternalId, null));
}
public Task<MaxSendResult> ForwardMessageAsync(
string externalChatId,
string sourceExternalChatId,
string sourceExternalMessageId,
CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(true, MessageExternalId, null));
}
public Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
@@ -2586,8 +2903,13 @@ public sealed class ApiSmokeTests : IDisposable
public Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken)
{
if (!_allowMediaDownload)
{
return Task.FromResult<MaxMediaDownload?>(null);
}
return Task.FromResult<MaxMediaDownload?>(
new MaxMediaDownload(new MemoryStream(RemoteBytes), "image/jpeg", RemoteBytes.Length));
new MaxMediaDownload(new MemoryStream(_remoteBytes), _contentType, _remoteBytes.Length));
}
}
+91
View File
@@ -0,0 +1,91 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Auth;
using QMax.Api.Configuration;
using QMax.Api.Contracts;
using QMax.Api.Controllers;
using QMax.Api.Infrastructure.Max;
using Microsoft.Extensions.Options;
namespace QMax.Tests;
public sealed class TenantIsolationTests
{
[Fact]
public async Task Phone_challenge_issues_tokens_only_after_max_code_completion()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<QMaxDbContext>().UseSqlite(connection).Options;
var currentUser = new CurrentUserAccessor();
await using var db = new QMaxDbContext(options, currentUser);
using (currentUser.Push(null, bypassTenantFilter: true)) await db.Database.EnsureCreatedAsync();
var qmax = Options.Create(new QMaxOptions
{
PairingCode = "invite",
JwtSecret = "tenant-test-secret-that-is-at-least-32-characters"
});
var controller = new AuthController(db, new TokenService(qmax), qmax, new MockMaxBridgeClient(), currentUser);
var started = await controller.BeginPhoneAuth(
new BeginPhoneAuthRequest("8 (900) 000-00-01", "test phone", "invite"),
CancellationToken.None);
var challenge = Assert.IsType<PhoneAuthChallengeResponse>(started.Value);
Assert.NotEqual(Guid.Empty, challenge.ChallengeId);
Assert.Empty(await db.UserSessions.IgnoreQueryFilters().ToArrayAsync());
var completed = await controller.CompletePhoneAuth(
new CompletePhoneAuthRequest(challenge.ChallengeId, challenge.ChallengeToken, "123456"),
CancellationToken.None);
var auth = Assert.IsType<AuthResponse>(completed.Value);
Assert.False(string.IsNullOrWhiteSpace(auth.AccessToken));
Assert.Single(await db.UserSessions.IgnoreQueryFilters().ToArrayAsync());
Assert.Equal("+79000000001", auth.User.PhoneNumber);
}
[Fact]
public async Task Chats_and_messages_are_visible_only_to_the_current_user()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<QMaxDbContext>().UseSqlite(connection).Options;
var currentUser = new CurrentUserAccessor();
var firstUser = new User { PhoneNumber = "+79000000001" };
var secondUser = new User { PhoneNumber = "+79000000002" };
await using (var setup = new QMaxDbContext(options, currentUser))
using (currentUser.Push(null, bypassTenantFilter: true))
{
await setup.Database.EnsureCreatedAsync();
setup.Users.AddRange(firstUser, secondUser);
await setup.SaveChangesAsync();
}
Guid firstChatId;
await using (var db = new QMaxDbContext(options, currentUser))
using (currentUser.Push(firstUser.Id))
{
var chat = new Chat { ExternalId = "same-max-chat", Title = "First" };
chat.Messages.Add(new Message { Text = "first secret", Direction = MessageDirection.Incoming });
db.Chats.Add(chat);
await db.SaveChangesAsync();
firstChatId = chat.Id;
}
await using (var db = new QMaxDbContext(options, currentUser))
using (currentUser.Push(secondUser.Id))
{
var chat = new Chat { ExternalId = "same-max-chat", Title = "Second" };
chat.Messages.Add(new Message { Text = "second secret", Direction = MessageDirection.Incoming });
db.Chats.Add(chat);
await db.SaveChangesAsync();
Assert.Single(await db.Chats.ToArrayAsync());
Assert.Equal("Second", (await db.Chats.SingleAsync()).Title);
Assert.Single(await db.Messages.ToArrayAsync());
Assert.False(await db.Chats.AnyAsync(x => x.Id == firstChatId));
}
}
}
-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