Compare commits

...
10 Commits
54 changed files with 4692 additions and 517 deletions
+2
View File
@@ -6,6 +6,8 @@ obj/
build/
artifacts/
node_modules/
__pycache__/
*.py[cod]
local.properties
*.user
*.suo
+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"
+13 -16
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 uses PyMax as the only MAX bridge.
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.
- `pymax-worker` - Python/PyMax worker with a persistent MAX mobile API session.
- `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.
@@ -44,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 linked to the MAX account used by PyMax.
- `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**. When MAX sends the confirmation code, submit it on the same page or from the Android app settings.
The worker stores PyMax session state in the `qmax-pymax-session` Docker volume, so the MAX session should survive restarts until MAX expires the login token.
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
@@ -73,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.
@@ -128,13 +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 through PyMax and currently maps:
The worker code maps:
- 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;
- explicit session status and phone-code re-login from Android settings.
- explicit per-user session status and phone-code re-login from Android settings.
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 = 54
versionName = "0.1.53"
versionCode = 60
versionName = "1.0.3"
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
+11
View File
@@ -5,6 +5,7 @@
<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
@@ -50,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
@@ -28,6 +33,7 @@ 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
@@ -68,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()
@@ -327,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) }
}
@@ -427,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)
@@ -22,6 +22,7 @@ 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
@@ -29,6 +30,8 @@ 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
@@ -73,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))
}
@@ -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
}
}
@@ -22,6 +22,7 @@ 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
@@ -32,12 +33,17 @@ 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,
@@ -133,7 +139,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
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 {
@@ -162,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) {
@@ -263,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 {
@@ -492,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(),
@@ -566,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(),
@@ -638,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) {
@@ -645,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
)
@@ -699,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)
@@ -715,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,
@@ -730,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
)
@@ -833,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
@@ -945,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)
}
@@ -1424,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 {
@@ -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
fun QMaxTheme(content: @Composable () -> Unit) {
@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(
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 -4
View File
@@ -5,11 +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 the phone number linked to the MAX account used by PyMax.
QMAX_MAX_PHONE_NUMBER=79000000000
QMAX_CORS_ALLOWED_ORIGINS=
# Optional Firebase Cloud Messaging. Mount the service account JSON into the API container
-2
View File
@@ -13,7 +13,6 @@ 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-pymax-worker:3002
QMax__CorsAllowedOrigins: ${QMAX_CORS_ALLOWED_ORIGINS:-}
@@ -40,7 +39,6 @@ services:
restart: unless-stopped
environment:
PORT: 3002
PYMAX_PHONE_NUMBER: ${QMAX_MAX_PHONE_NUMBER}
PYMAX_SESSION_DIR: /data/pymax-session
PYMAX_SEND_ROOTS: /qmax-data:/tmp
PYMAX_CHAT_FETCH_LIMIT: ${QMAX_PYMAX_CHAT_FETCH_LIMIT:-350}
+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.
+10 -6
View File
@@ -4,10 +4,10 @@
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/>Python + PyMax"]
E -->|persistent PyMax session| F["MAX mobile API"]
E -->|isolated session per user| F["MAX mobile API"]
B --> G["Caddy TLS<br/>qmax.kusoft.xyz"]
```
@@ -15,20 +15,24 @@ The server is the only public backend surface. The PyMax worker stays inside the
## 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 session files 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
- PyMax login is authorized on the Raspberry Pi worker.
- 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 PyMax session-expiration monitoring active.
+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-")
+165 -35
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import base64
import contextvars
import json
import mimetypes
import os
@@ -21,12 +22,14 @@ from aiohttp import web
from pymax import Client, ExtraConfig, File, Photo, SyncOverrides, Video
from pymax.types import ContactInfo
from .chat_identity import normalize_chat_kind_value, resolve_sender_name
from .message_labels import INCOMING_CALL_TEXT, is_call_media_label
PORT = int(os.environ.get("PORT", "3002"))
PHONE_NUMBER = os.environ.get("PYMAX_PHONE_NUMBER") or os.environ.get("QMAX_MAX_PHONE_NUMBER") or ""
SESSION_DIR = pathlib.Path(os.environ.get("PYMAX_SESSION_DIR", "/data/pymax-session"))
SESSION_ROOT = pathlib.Path(os.environ.get("PYMAX_SESSION_DIR", "/data/pymax-session"))
SESSION_NAME = os.environ.get("PYMAX_SESSION_NAME", "session.db")
PHONE_CONTACT_IDS_FILE = SESSION_DIR / "phone-contact-ids.json"
CHAT_FETCH_LIMIT = int(os.environ.get("PYMAX_CHAT_FETCH_LIMIT", "350"))
HISTORY_LIMIT = int(os.environ.get("PYMAX_HISTORY_LIMIT", "80"))
SEND_ROOTS = [pathlib.Path(p) for p in os.environ.get("PYMAX_SEND_ROOTS", "/qmax-data:/tmp").split(":") if p]
@@ -80,16 +83,16 @@ def is_expired_session_error(error: str | None) -> bool:
)
def archive_session_dir() -> str | None:
if not SESSION_DIR.exists():
def archive_session_dir(session_dir: pathlib.Path) -> str | None:
if not session_dir.exists():
return None
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
target = SESSION_DIR.with_name(f"{SESSION_DIR.name}.expired-{stamp}")
target = session_dir.with_name(f"{session_dir.name}.expired-{stamp}")
suffix = 1
while target.exists():
target = SESSION_DIR.with_name(f"{SESSION_DIR.name}.expired-{stamp}-{suffix}")
target = session_dir.with_name(f"{session_dir.name}.expired-{stamp}-{suffix}")
suffix += 1
shutil.move(str(SESSION_DIR), str(target))
shutil.move(str(session_dir), str(target))
return str(target)
@@ -372,7 +375,9 @@ class DeferredPasswordProvider:
class PyMaxRuntime:
def __init__(self) -> None:
def __init__(self, session_dir: pathlib.Path, default_phone: str = "") -> None:
self.session_dir = session_dir
self.phone = default_phone
self.client: Client | None = None
self.task: asyncio.Task[None] | None = None
self.ready = asyncio.Event()
@@ -409,15 +414,22 @@ class PyMaxRuntime:
return
self.ready.clear()
self.last_error = None
use_phone = normalize_phone(phone or PHONE_NUMBER)
stored_phone_file = self.session_dir / "phone.txt"
stored_phone = ""
with suppress(OSError):
stored_phone = stored_phone_file.read_text(encoding="utf-8").strip()
use_phone = normalize_phone(phone or self.phone or stored_phone or PHONE_NUMBER)
if not use_phone:
self.last_error = "PYMAX_PHONE_NUMBER is not configured."
return
SESSION_DIR.mkdir(parents=True, exist_ok=True)
SESSION_DIR.chmod(0o700)
self.phone = use_phone
self.session_dir.mkdir(parents=True, exist_ok=True)
self.session_dir.chmod(0o700)
stored_phone_file.write_text(use_phone, encoding="utf-8")
stored_phone_file.chmod(0o600)
client = Client(
phone=use_phone,
work_dir=str(SESSION_DIR),
work_dir=str(self.session_dir),
session_name=SESSION_NAME,
sms_code_provider=self.code_provider,
password_provider=self.password_provider,
@@ -429,6 +441,7 @@ class PyMaxRuntime:
@client.on_start()
async def on_start(c: Client) -> None:
self.client = c
self.last_error = None
me = getattr(c, "me", None)
self.last_title = str(getattr(me, "first_name", "") or getattr(me, "name", "") or "PyMax")
self.last_started_at = utc_now()
@@ -474,7 +487,7 @@ class PyMaxRuntime:
async with self.lock:
await self._stop_locked()
self.last_error = None
archive_session_dir()
archive_session_dir(self.session_dir)
async def _stop_locked(self) -> None:
client = self.client
@@ -493,7 +506,35 @@ class PyMaxRuntime:
await task
runtime = PyMaxRuntime()
class RuntimeRegistry:
def __init__(self) -> None:
self._runtimes: dict[str, PyMaxRuntime] = {}
def get(self, account_id: str | None) -> PyMaxRuntime:
key = (account_id or "legacy").strip().lower()
if key != "legacy" and (len(key) != 32 or any(ch not in "0123456789abcdef" for ch in key)):
raise ValueError("X-QMax-Account-Id must be a 32-character hexadecimal UUID.")
runtime = self._runtimes.get(key)
if runtime is None:
session_dir = SESSION_ROOT if key == "legacy" else SESSION_ROOT / "accounts" / key
runtime = PyMaxRuntime(session_dir)
self._runtimes[key] = runtime
return runtime
async def stop(self) -> None:
await asyncio.gather(*(runtime.stop() for runtime in self._runtimes.values()), return_exceptions=True)
registry = RuntimeRegistry()
runtime_context: contextvars.ContextVar[PyMaxRuntime] = contextvars.ContextVar("qmax_runtime")
class RuntimeProxy:
def __getattr__(self, name: str) -> Any:
return getattr(runtime_context.get(), name)
runtime = RuntimeProxy()
def json_response(data: Any, status: int = 200) -> web.Response:
@@ -532,10 +573,7 @@ def attachment_preview(attaches: list[Any]) -> str | None:
def normalize_chat_kind(chat: Any) -> str:
chat_type = value_name(getattr(chat, "type", "")).upper()
if "CHANNEL" in chat_type:
return "Channel"
return "MaxDialog"
return normalize_chat_kind_value(getattr(chat, "type", ""))
def normalize_attachment_kind(att: Any) -> str:
@@ -543,6 +581,8 @@ def normalize_attachment_kind(att: Any) -> str:
raw_type = value_name(data.get("type") or data.get("_type") or getattr(att, "type", "")).lower()
class_name = type(att).__name__.lower()
merged = f"{raw_type} {class_name}"
if is_call_media_label(raw_type) or "callattachment" in class_name:
return "call"
if "photo" in merged:
return "photo"
if "video" in merged:
@@ -562,6 +602,8 @@ def attachment_preview(attaches: list[Any]) -> str | None:
if not attaches:
return None
kinds = [normalize_attachment_kind(att) for att in attaches]
if any(is_call_media_label(kind) for kind in kinds):
return INCOMING_CALL_TEXT
if any(kind == "photo" for kind in kinds):
return "\u0424\u043e\u0442\u043e"
if any(kind == "video" for kind in kinds):
@@ -649,8 +691,12 @@ async def normalize_attachment(client: Client, message: Any, att: Any, sort_orde
req = await client.get_video_by_id(int(chat_id), int(message_id), int(data["video_id"]))
req_data = dump_model(req)
remote_url = req_data.get("url") or remote_url
except Exception: # noqa: BLE001
pass
except Exception as exc: # noqa: BLE001
print(
f"Failed to resolve MAX {kind} download URL "
f"(chat_id={chat_id}, message_id={message_id}): {type(exc).__name__}: {exc}",
flush=True,
)
if kind == "photo":
content_type = "image/jpeg"
@@ -687,21 +733,31 @@ async def normalize_message(
message: Any,
user_map: dict[str, Any] | None = None,
me_id: int | None = None,
phone_contact_names: dict[str, str] | None = None,
) -> dict[str, Any]:
attaches = list(getattr(message, "attaches", None) or [])
call_attaches = [att for att in attaches if normalize_attachment_kind(att) == "call"]
downloadable_attaches = [att for att in attaches if normalize_attachment_kind(att) != "call"]
attachments = [
await normalize_attachment(client, message, att, index)
for index, att in enumerate(attaches)
for index, att in enumerate(downloadable_attaches)
]
sender = getattr(message, "sender", None)
sender_id = coerce_int(sender)
if me_id is None:
me_id = get_me_user_id(client)
is_outgoing = sender_id is not None and me_id is not None and sender_id == me_id
text = getattr(message, "text", None) or attachment_preview(attaches)
raw_text = getattr(message, "text", None)
is_call_text = is_call_media_label(raw_text)
text = INCOMING_CALL_TEXT if call_attaches or is_call_text else raw_text or attachment_preview(attaches)
status = value_name(getattr(message, "status", None)).lower() or None
user = (user_map or {}).get(str(sender_id)) if sender_id is not None else None
sender_name = "You" if is_outgoing else user_display_name(user)
sender_name = resolve_sender_name(
sender_id,
is_outgoing=is_outgoing,
phone_contact_names=phone_contact_names,
profile_name=user_display_name(user),
)
return {
"externalId": clean_id(getattr(message, "id", None)),
"senderExternalId": clean_id(sender) or None,
@@ -720,6 +776,7 @@ async def normalize_chat_update(
include_history: bool = False,
user_map: dict[str, Any] | None = None,
me_id: int | None = None,
phone_contact_names: dict[str, str] | None = None,
) -> dict[str, Any]:
last_message = getattr(chat, "last_message", None)
raw_messages: list[Any] = []
@@ -734,7 +791,12 @@ async def normalize_chat_update(
me_id = get_me_user_id(client)
if user_map is None:
user_map = await build_user_map(client, [chat], raw_messages)
messages = [await normalize_message(client, chat, m, user_map, me_id) for m in raw_messages]
if phone_contact_names is None:
phone_contact_names = load_phone_contact_names()
messages = [
await normalize_message(client, chat, m, user_map, me_id, phone_contact_names)
for m in raw_messages
]
last_preview = None
last_at = getattr(chat, "last_event_time", None)
@@ -890,7 +952,8 @@ async def status(_request: web.Request) -> web.Response:
async def login_start(request: web.Request) -> web.Response:
data = await read_json(request)
phone = str(data.get("phoneNumber") or PHONE_NUMBER).strip()
if not runtime.is_authorized and runtime.login_stage() not in {"Code", "Password"}:
force = data.get("force") is True
if force or (not runtime.is_authorized and runtime.login_stage() not in {"Code", "Password"}):
await runtime.reset_session_for_login()
await runtime.ensure_started(phone)
return json_response(status_payload())
@@ -969,18 +1032,44 @@ async def contacts(_request: web.Request) -> web.Response:
def load_phone_contact_ids() -> set[int]:
path = runtime.session_dir / "phone-contact-ids.json"
try:
values = json.loads(PHONE_CONTACT_IDS_FILE.read_text(encoding="utf-8"))
values = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return set()
return {value for item in values if (value := coerce_int(item)) is not None and value > 0}
def save_phone_contact_ids(contact_ids: set[int]) -> None:
SESSION_DIR.mkdir(parents=True, exist_ok=True)
part_path = PHONE_CONTACT_IDS_FILE.with_suffix(".json.part")
runtime.session_dir.mkdir(parents=True, exist_ok=True)
path = runtime.session_dir / "phone-contact-ids.json"
part_path = path.with_suffix(".json.part")
part_path.write_text(json.dumps(sorted(contact_ids)), encoding="utf-8")
part_path.replace(PHONE_CONTACT_IDS_FILE)
part_path.replace(path)
def load_phone_contact_names() -> dict[str, str]:
path = runtime.session_dir / "phone-contact-names.json"
try:
values = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return {}
if not isinstance(values, dict):
return {}
return {
str(contact_id): str(name).strip()
for contact_id, name in values.items()
if coerce_int(contact_id) is not None and str(name).strip()
}
def save_phone_contact_names(contact_names: dict[int, str]) -> None:
runtime.session_dir.mkdir(parents=True, exist_ok=True)
path = runtime.session_dir / "phone-contact-names.json"
part_path = path.with_suffix(".json.part")
values = {str(contact_id): name.strip() for contact_id, name in contact_names.items() if name.strip()}
part_path.write_text(json.dumps(values, ensure_ascii=False, sort_keys=True), encoding="utf-8")
part_path.replace(path)
def append_saved_contacts(client: Client, contacts: list[Any]) -> None:
@@ -1007,7 +1096,7 @@ async def contacts_import(request: web.Request) -> web.Response:
return json_response({"success": False, "error": "contacts limit is 5000"}, status=400)
contacts_to_import: list[ContactInfo] = []
requested_phone_keys: set[str] = set()
requested_names_by_phone: dict[str, str] = {}
for item in raw_contacts:
if not isinstance(item, dict):
continue
@@ -1015,8 +1104,8 @@ async def contacts_import(request: web.Request) -> web.Response:
first_name = str(item.get("firstName") or item.get("name") or phone).strip()
last_name = str(item.get("lastName") or "").strip() or None
key = phone_key(phone)
if phone and key and key not in requested_phone_keys:
requested_phone_keys.add(key)
if phone and key and key not in requested_names_by_phone:
requested_names_by_phone[key] = " ".join(part for part in (first_name, last_name) if part).strip()
contacts_to_import.append(ContactInfo(phone=phone, first_name=first_name or phone, last_name=last_name))
if not contacts_to_import:
return json_response({"success": False, "error": "contacts contain no phone numbers"}, status=400)
@@ -1039,7 +1128,7 @@ async def contacts_import(request: web.Request) -> web.Response:
if (
contact_id is not None
and contact_id > 0
and phone_key(contact_data.get("phone") or getattr(contact, "phone", None)) in requested_phone_keys
and phone_key(contact_data.get("phone") or getattr(contact, "phone", None)) in requested_names_by_phone
):
matched_by_id[contact_id] = contact
@@ -1051,6 +1140,15 @@ async def contacts_import(request: web.Request) -> web.Response:
and contact_id > 0
}
save_phone_contact_ids(imported_ids)
phone_names_by_id = {
contact_id: requested_names_by_phone[contact_phone_key]
for contact in matched
if (contact_data := dump_model(contact))
if (contact_id := coerce_int(contact_data.get("id") or getattr(contact, "id", None))) is not None
if (contact_phone_key := phone_key(contact_data.get("phone") or getattr(contact, "phone", None)))
in requested_names_by_phone
}
save_phone_contact_names(phone_names_by_id)
me_id = get_me_user_id(client)
result = [
normalized
@@ -1207,6 +1305,24 @@ async def send_attachment(request: web.Request) -> web.Response:
return json_response({"success": bool(external_id), "externalMessageId": external_id or None, "error": None if external_id else "PyMax returned no message id.", "chatUrl": f"pymax://chat/{chat_id}"})
@route_errors
async def forward_message(request: web.Request) -> web.Response:
data = await read_json(request)
client = await runtime.get_client()
chat_id = parse_chat_id(data.get("externalChatId"))
source_chat_id = parse_chat_id(data.get("sourceExternalChatId"))
source_message_id = str(data.get("sourceExternalMessageId") or "").strip()
if not source_message_id:
raise ValueError("sourceExternalMessageId is required")
sent = await client.forward_message(
chat_id=chat_id,
message_id=source_message_id,
source_chat_id=source_chat_id,
)
external_id = clean_id(getattr(sent, "id", None))
return json_response({"success": bool(external_id), "externalMessageId": external_id or None, "error": None if external_id else "PyMax returned no forwarded message id.", "chatUrl": f"pymax://chat/{chat_id}"})
@route_errors
async def media_fetch(request: web.Request) -> web.StreamResponse:
remote_url = request.query.get("url")
@@ -1223,8 +1339,21 @@ async def media_fetch(request: web.Request) -> web.StreamResponse:
)
@web.middleware
async def account_runtime_middleware(request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]]) -> web.StreamResponse:
try:
selected = registry.get(request.headers.get("X-QMax-Account-Id"))
except ValueError as exc:
return json_response({"success": False, "error": str(exc)}, status=400)
token = runtime_context.set(selected)
try:
return await handler(request)
finally:
runtime_context.reset(token)
def create_app() -> web.Application:
app = web.Application(client_max_size=1024 * 1024)
app = web.Application(client_max_size=1024 * 1024, middlewares=[account_runtime_middleware])
app.router.add_get("/health", health)
app.router.add_get("/status", status)
app.router.add_post("/login/start", login_start)
@@ -1244,6 +1373,7 @@ def create_app() -> web.Application:
app.router.add_post("/chat/delete", chat_delete)
app.router.add_post("/send/text", send_text)
app.router.add_post("/send/attachment", send_attachment)
app.router.add_post("/message/forward", forward_message)
app.router.add_post("/message/edit", disabled_action)
app.router.add_post("/message/delete", disabled_action)
app.router.add_post("/message/reaction", disabled_action)
@@ -1253,7 +1383,7 @@ def create_app() -> web.Application:
async def on_cleanup(_app: web.Application) -> None:
await runtime.stop()
await registry.stop()
if __name__ == "__main__":
+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()
+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,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);
+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);
}
+14 -11
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;
@@ -64,7 +65,7 @@ public sealed class ChatsController(
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return projection.ToDto(chat);
}
@@ -102,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
@@ -240,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();
@@ -279,7 +280,7 @@ 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();
}
@@ -327,7 +328,7 @@ 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();
}
@@ -450,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;
}
@@ -504,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;
}
@@ -541,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();
}
@@ -679,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"
@@ -717,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;
}
@@ -785,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;
}
@@ -929,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;
}
+14 -10
View File
@@ -10,6 +10,7 @@ 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;
@@ -21,11 +22,8 @@ public sealed class MaxController(
MaxBridgeSyncService syncService,
QMaxDbContext db,
IHubContext<QMaxHub> hubContext,
ChatProjectionService projection,
IOptions<QMaxOptions> options) : ControllerBase
ChatProjectionService projection) : ControllerBase
{
private readonly QMaxOptions _options = options.Value;
[HttpGet("status")]
public async Task<ActionResult<MaxBridgeStatusDto>> Status(CancellationToken cancellationToken)
{
@@ -37,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.");
@@ -111,27 +114,28 @@ public sealed class MaxController(
return BadRequest("Channel was joined but was not saved in QMAX.");
}
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
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.All.SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
await hubContext.Clients.User(userId.ToString()).SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
}
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status)
+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,6 +4,8 @@ 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);
@@ -30,6 +32,14 @@ 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);
@@ -45,6 +45,9 @@ 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>>([
@@ -103,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));
@@ -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.");
}
@@ -120,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>(
@@ -171,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);
@@ -217,6 +245,7 @@ public sealed class WorkerMaxBridgeClient(
{
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);
@@ -239,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);
@@ -128,8 +128,9 @@ public sealed class AttachmentStorageService(IOptions<QMaxOptions> options) : IA
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
}
var resolvedContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
var resolvedKind = preferredKind ?? GuessKind(resolvedContentType, extension);
var 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);
@@ -167,6 +168,32 @@ public sealed class AttachmentStorageService(IOptions<QMaxOptions> options) : IA
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());
+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,9 +138,13 @@ 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,
+192 -28
View File
@@ -10,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;
@@ -17,6 +18,7 @@ public sealed class MaxBridgeSyncService(
IServiceScopeFactory scopeFactory,
IMaxBridgeClient maxBridgeClient,
IHubContext<QMaxHub> hubContext,
ICurrentUserAccessor currentUser,
ILogger<MaxBridgeSyncService> logger)
{
private const string PreviewExternalIdPrefix = "preview:";
@@ -44,22 +46,22 @@ public sealed class MaxBridgeSyncService(
var status = await maxBridgeClient.GetStatusAsync(cancellationToken);
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var options = scope.ServiceProvider.GetRequiredService<IOptions<QMaxOptions>>().Value;
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
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 { 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.All.SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
await hubContext.Clients.User(userId.ToString()).SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
}
catch (Exception statusError)
{
@@ -407,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);
@@ -465,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
@@ -1022,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,
@@ -1033,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;
@@ -1146,6 +1176,8 @@ public sealed class MaxBridgeSyncService(
}
private static async Task<MessageAttachment?> CreateAttachmentAsync(
QMaxDbContext db,
MaxMessageUpdate incomingMessage,
MaxAttachmentUpdate incoming,
IMaxBridgeClient maxBridgeClient,
IAttachmentStorageService storage,
@@ -1219,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
};
@@ -1279,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,
@@ -1339,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);
}
@@ -1401,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;
}
+13 -1
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);
@@ -276,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)
{
@@ -338,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))
+293 -11
View File
@@ -365,7 +365,7 @@ public sealed class ApiSmokeTests : IDisposable
var stored = await storage.SaveRemoteAsync(
"voice.m4a",
"audio/mp4",
"application/octet-stream",
stream,
bytes.Length,
null,
@@ -376,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);
@@ -479,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()
{
@@ -1487,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()
{
@@ -1771,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
{
@@ -1810,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();
}
@@ -1825,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
{
@@ -2509,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)
{
@@ -2549,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)
])
],
@@ -2604,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));
@@ -2626,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));
}
}
}