Switch MAX bridge to PyMax only

This commit is contained in:
sevenhill
2026-07-07 14:18:49 +03:00
parent 8efeff334a
commit af84343e19
19 changed files with 314 additions and 8088 deletions
+12 -17
View File
@@ -1,11 +1,11 @@
# QMAX
QMAX is a private Android messenger client backed by a personal bridge server. The Android app talks to your QMAX server, and the server keeps a browser session for `https://web.max.ru`.
QMAX is a private Android messenger client backed by a personal bridge server. The Android app talks to your QMAX server, and the server uses PyMax as the only MAX bridge.
Current shape:
- `server/QMax.Api` - ASP.NET Core API, SQLite cache, JWT pairing auth, SignalR hub, attachment storage, APK update catalog.
- `worker` - Node/Playwright worker with a persistent MAX Web browser profile.
- `pymax-worker` - Python/PyMax worker with a persistent MAX mobile API session.
- `android` - Kotlin + Jetpack Compose Android client pointed at `https://qmax.kusoft.xyz`.
- `deploy` - Docker Compose + Caddy for Raspberry Pi 5.
@@ -13,11 +13,9 @@ Current shape:
```powershell
dotnet test QMax.slnx
python -m py_compile pymax-worker/src/server.py
cd android
.\gradlew.bat :app:assembleDebug :app:assembleRelease --console=plain --no-daemon
cd ..\worker
npm.cmd install
node --check src/server.js
```
## Raspberry Pi Deployment
@@ -47,7 +45,7 @@ Set strong values in `.env`:
- `QMAX_JWT_SECRET` - at least 32 random characters.
- `QMAX_PAIRING_CODE` - one-time-ish pairing password for your Android client and `/admin/max`.
- `QMAX_MAX_PHONE_NUMBER` - the phone number used for MAX Web login.
- `QMAX_MAX_PHONE_NUMBER` - the phone number linked to the MAX account used by PyMax.
Secrets must stay in `.env`, not in git.
@@ -59,9 +57,9 @@ Open:
https://qmax.kusoft.xyz/admin/max?pairingCode=YOUR_PAIRING_CODE
```
Use **Start phone login**. If MAX shows a robot check, solve it manually on this page using the screenshot plus click/type forms. When MAX sends the confirmation code, submit it on the same page or from the Android app MAX panel.
Use **Start phone login**. When MAX sends the confirmation code, submit it on the same page or from the Android app settings.
The worker stores browser state in the `qmax-max-profile` Docker volume, so the MAX session should survive restarts.
The worker stores PyMax session state in the `qmax-pymax-session` Docker volume, so the MAX session should survive restarts until MAX expires the login token.
## Android Pairing
@@ -132,14 +130,11 @@ The Android app checks this manifest, compares semantic versions, downloads the
## Current MAX Mapping Status
The deployed worker is authorized in MAX Web and currently maps:
The deployed worker is authorized through PyMax and currently maps:
- chat list extraction from the left MAX Web dialog list;
- visible chat history extraction when Android opens a dialog;
- text sending through the MAX Web composer;
- attachment upload through the MAX Web file chooser;
- image, video, file and voice attachment projection from MAX Web;
- chat list and message history through PyMax;
- text sending through PyMax;
- attachment upload through PyMax file/photo/video models;
- image, video, file and voice attachment projection through the API;
- Android image attachment caching with `.part` downloads before a file is shown from local storage;
- manual browser inspection endpoints for future selector changes.
The bridge uses DOM selectors in `worker/src/server.js`, so MAX Web UI changes may require a worker redeploy without changing the Android app contract.
- explicit session status and phone-code re-login from Android settings.
@@ -164,6 +164,7 @@ import kotlinx.coroutines.withContext
import xyz.kusoft.qmax.core.push.ForegroundChatTracker
import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.QMaxSession
import xyz.kusoft.qmax.ui.QMaxUiState
@@ -296,7 +297,7 @@ private fun LoginScreen(vm: QMaxViewModel) {
verticalArrangement = Arrangement.Center
) {
Text("QMAX", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = QMaxBlue)
Text("MAX Web в привычном мессенджере", color = QMaxMuted, modifier = Modifier.padding(top = 6.dp))
Text("MAX в привычном мессенджере", color = QMaxMuted, modifier = Modifier.padding(top = 6.dp))
Spacer(Modifier.height(28.dp))
OutlinedTextField(
value = state.serverUrl,
@@ -370,6 +371,10 @@ private fun ChatListScreen(vm: QMaxViewModel) {
onMenuOpenChange = { menuOpen = it },
onRefresh = vm::loadChats,
onCheckUpdates = vm::checkArgusUpdate,
onRefreshMaxStatus = vm::loadMaxStatus,
onStartMaxLogin = vm::startMaxLogin,
onMaxCode = vm::updateMaxCode,
onSubmitMaxCode = vm::submitMaxCode,
onLogout = vm::logout,
onSearch = vm::updateSearchQuery,
onTabSelected = { tab ->
@@ -596,6 +601,10 @@ private fun TelegramChatListContent(
onMenuOpenChange: (Boolean) -> Unit,
onRefresh: () -> Unit,
onCheckUpdates: () -> Unit,
onRefreshMaxStatus: () -> Unit,
onStartMaxLogin: () -> Unit,
onMaxCode: (String) -> Unit,
onSubmitMaxCode: () -> Unit,
onLogout: () -> Unit,
onSearch: (String) -> Unit,
onTabSelected: (MainTab) -> Unit,
@@ -648,10 +657,17 @@ private fun TelegramChatListContent(
)
}
ErrorLine(state.error)
if (activeTab != MainTab.Settings && state.maxStatus?.isAuthorized == false) {
MaxSessionExpiredBanner(onClick = { onTabSelected(MainTab.Settings) })
}
if (activeTab == MainTab.Settings) {
SettingsTabContent(
state = state,
onCheckUpdates = onCheckUpdates,
onRefreshMaxStatus = onRefreshMaxStatus,
onStartMaxLogin = onStartMaxLogin,
onMaxCode = onMaxCode,
onSubmitMaxCode = onSubmitMaxCode,
onRefresh = onRefresh,
onLogout = onLogout
)
@@ -1039,10 +1055,29 @@ private fun ChatListFloatingActions(modifier: Modifier = Modifier, onNewChat: ()
}
}
@Composable
private fun MaxSessionExpiredBanner(onClick: () -> Unit) {
Surface(
color = Color(0xFFFFF3E0),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Column(Modifier.padding(horizontal = 20.dp, vertical = 10.dp)) {
Text("Сессия MAX закончилась", fontWeight = FontWeight.SemiBold, color = Color(0xFF9A4D00))
Text("Откройте настройки, получите новый код и отдайте его QMAX.", color = QMaxMuted, style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun SettingsTabContent(
state: QMaxUiState,
onCheckUpdates: () -> Unit,
onRefreshMaxStatus: () -> Unit,
onStartMaxLogin: () -> Unit,
onMaxCode: (String) -> Unit,
onSubmitMaxCode: () -> Unit,
onRefresh: () -> Unit,
onLogout: () -> Unit
) {
@@ -1066,19 +1101,67 @@ private fun SettingsTabContent(
HorizontalDivider(color = Color(0xFFE7EEF3))
}
item {
val status = state.maxStatus
val loginAvailable = status?.isAuthorized == false
val waitingForCode = isMaxLoginWaitingForCode(status)
val requestCodeEnabled = loginAvailable && !waitingForCode && !state.loading
val submitCodeEnabled = loginAvailable && state.maxCode.trim().isNotBlank() && !state.loading
Spacer(Modifier.height(22.dp))
Text("MAX", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = QMaxText)
Spacer(Modifier.height(8.dp))
Text(
state.maxStatus?.status ?: "Статус неизвестен",
maxStatusTitle(status),
style = MaterialTheme.typography.bodyLarge,
color = QMaxMuted
color = if (loginAvailable) Color(0xFFC62828) else QMaxMuted
)
state.maxStatus?.lastError?.takeIf { it.isNotBlank() }?.let {
Text(
maxStatusHint(status),
style = MaterialTheme.typography.bodyMedium,
color = QMaxMuted,
modifier = Modifier.padding(top = 4.dp)
)
status?.lastError?.takeIf { it.isNotBlank() }?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodyMedium, color = Color(0xFFC62828))
}
Spacer(Modifier.height(12.dp))
Column {
Button(
onClick = onRefreshMaxStatus,
enabled = !state.loading,
modifier = Modifier.fillMaxWidth()
) {
Text("Обновить статус")
}
Spacer(Modifier.height(8.dp))
Button(
onClick = onStartMaxLogin,
enabled = requestCodeEnabled,
modifier = Modifier.fillMaxWidth()
) {
Text(if (waitingForCode) "Код запрошен" else "Получить новый код")
}
}
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = state.maxCode,
onValueChange = onMaxCode,
enabled = loginAvailable && !state.loading,
label = { Text("Новый код MAX") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
Button(
onClick = onSubmitMaxCode,
enabled = submitCodeEnabled,
modifier = Modifier.fillMaxWidth()
) {
Text("Отдать код")
}
Spacer(Modifier.height(22.dp))
HorizontalDivider(color = Color(0xFFE7EEF3))
Spacer(Modifier.height(22.dp))
Button(onClick = onRefresh, enabled = !state.loading) {
Text("Обновить чаты")
}
@@ -1090,6 +1173,31 @@ private fun SettingsTabContent(
}
}
private fun isMaxLoginWaitingForCode(status: MaxBridgeStatusDto?): Boolean {
return status?.loginStage.equals("Code", ignoreCase = true) ||
status?.status.equals("Code", ignoreCase = true)
}
private fun maxStatusTitle(status: MaxBridgeStatusDto?): String {
return when {
status == null -> "Статус неизвестен"
status.isAuthorized -> "Сессия MAX активна"
status.loginStage.equals("Expired", ignoreCase = true) ||
status.status.equals("SessionExpired", ignoreCase = true) -> "Сессия MAX закончилась"
isMaxLoginWaitingForCode(status) -> "MAX ждёт код подтверждения"
else -> "MAX требует вход"
}
}
private fun maxStatusHint(status: MaxBridgeStatusDto?): String {
return when {
status == null -> "Проверка статуса выполняется автоматически. Можно обновить статус вручную."
status.isAuthorized -> "Получение нового кода будет доступно только после окончания сессии."
isMaxLoginWaitingForCode(status) -> "Введите код из MAX и нажмите «Отдать код»."
else -> "Нажмите «Получить новый код», затем введите код подтверждения."
}
}
@Composable
private fun TelegramBottomNavigation(
modifier: Modifier = Modifier,
@@ -8,6 +8,7 @@ import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MessageDeletedDto
import xyz.kusoft.qmax.core.model.MessageDto
import xyz.kusoft.qmax.core.model.QMaxSession
@@ -26,7 +27,8 @@ class QMaxRealtimeClient {
onChatListInvalidated: () -> Unit,
onMessageCreated: (MessageDto) -> Unit,
onMessageUpdated: (MessageDto) -> Unit,
onMessageDeleted: (MessageDeletedDto) -> Unit
onMessageDeleted: (MessageDeletedDto) -> Unit,
onMaxStatusChanged: (MaxBridgeStatusDto) -> Unit
) = withContext(Dispatchers.IO) {
disconnect()
@@ -36,6 +38,15 @@ class QMaxRealtimeClient {
.build()
connection.on("ChatListInvalidated", onChatListInvalidated)
connection.on(
"MaxStatusChanged",
{ payload: JsonElement ->
runCatching {
onMaxStatusChanged(json.decodeFromString<MaxBridgeStatusDto>(payload.toString()))
}
},
JsonElement::class.java
)
connection.on(
"MessageCreated",
{ payload: JsonElement ->
@@ -72,6 +72,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
private var chatRefreshJob: Job? = null
private var chatRefreshGeneration = 0L
private var chatSearchJob: Job? = null
private var maxStatusPollJob: Job? = null
private var autoLoginJob: Job? = null
private var pushRegisteredForToken: String? = null
private var pushRegistrationJob: Job? = null
@@ -107,11 +108,12 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
loadChats()
connectRealtime(session)
registerPushDevice(session)
loadMaxStatus()
startMaxStatusPolling(session)
} else {
realtimeConnectJob?.cancel()
messagePollJob?.cancel()
presencePollJob?.cancel()
maxStatusPollJob?.cancel()
pushRegistrationJob?.cancel()
pushRegistrationInFlightForToken = null
pushRegisteredForToken = null
@@ -687,19 +689,20 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
fun loadMaxStatus() = launchLoading(false) {
val session = requireSession()
state.value = state.value.copy(maxStatus = repository.maxStatus(session))
refreshMaxStatus(session)
}
fun startMaxLogin() = launchLoading {
val session = requireSession()
state.value = state.value.copy(maxStatus = repository.startMaxLogin(session, null))
applyMaxStatus(repository.startMaxLogin(session, null))
}
fun submitMaxCode() = launchLoading {
val session = requireSession()
val code = state.value.maxCode.trim()
if (code.isBlank()) return@launchLoading
state.value = state.value.copy(maxStatus = repository.submitMaxCode(session, code), maxCode = "")
applyMaxStatus(repository.submitMaxCode(session, code))
state.value = state.value.copy(maxCode = "")
}
fun checkArgusUpdate() = launchLoading {
@@ -1044,6 +1047,28 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
}
}
private fun startMaxStatusPolling(session: QMaxSession) {
maxStatusPollJob?.cancel()
maxStatusPollJob = viewModelScope.launch {
while (true) {
runCatching {
refreshMaxStatus(session)
}.onFailure {
Log.w(LogTag, "MAX status refresh failed", it)
}
delay(MaxStatusPollIntervalMs)
}
}
}
private suspend fun refreshMaxStatus(session: QMaxSession) {
applyMaxStatus(repository.maxStatus(session))
}
private fun applyMaxStatus(status: MaxBridgeStatusDto) {
state.value = state.value.copy(maxStatus = status)
}
private fun connectRealtime(session: QMaxSession) {
realtimeConnectJob?.cancel()
realtimeConnectJob = viewModelScope.launch {
@@ -1053,7 +1078,8 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
onChatListInvalidated = ::scheduleChatListRefresh,
onMessageCreated = ::handleRealtimeMessage,
onMessageUpdated = ::handleRealtimeMessageUpdated,
onMessageDeleted = ::handleRealtimeMessageDeleted
onMessageDeleted = ::handleRealtimeMessageDeleted,
onMaxStatusChanged = ::applyMaxStatus
)
realtime.joinChat(state.value.selectedChat?.id)
}
@@ -1245,6 +1271,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
const val ChatListTimeoutMs = 45_000L
const val MessageSyncTimeoutMs = 120_000L
const val MessageHydrationIntervalMs = 120_000L
const val MaxStatusPollIntervalMs = 60_000L
const val AutoLoginDelayMs = 1_000L
const val PushRegistrationAttempts = 6
const val InitialPushRegistrationRetryMs = 15_000L
+1 -4
View File
@@ -8,11 +8,8 @@ QMAX_JWT_SECRET=change-me-to-a-long-random-secret-at-least-32-characters
# Enter this once on the Android login screen to pair the device with your private bridge.
QMAX_PAIRING_CODE=change-me-pairing-code
# Use international format if MAX accepts it in your flow, otherwise the local phone format you use in MAX Web.
# Use the phone number linked to the MAX account used by PyMax.
QMAX_MAX_PHONE_NUMBER=79000000000
# Set false temporarily if you attach a visible browser/VNC workflow for CAPTCHA/debug.
QMAX_MAX_HEADLESS=true
QMAX_CORS_ALLOWED_ORIGINS=
# Optional Firebase Cloud Messaging. Mount the service account JSON into the API container
-18
View File
@@ -32,23 +32,6 @@ services:
networks:
- qmax
qmax-max-worker:
build:
context: ../worker
dockerfile: Dockerfile
container_name: qmax-max-worker
restart: unless-stopped
environment:
PORT: 3001
MAX_BASE_URL: https://web.max.ru/
MAX_USER_DATA_DIR: /data/max-profile
MAX_HEADLESS: ${QMAX_MAX_HEADLESS:-true}
volumes:
- qmax-max-profile:/data
- qmax-data:/qmax-data
networks:
- qmax
qmax-pymax-worker:
build:
context: ../pymax-worker
@@ -70,7 +53,6 @@ services:
volumes:
qmax-data:
qmax-max-profile:
qmax-pymax-session:
networks:
+7 -7
View File
@@ -6,31 +6,31 @@ flowchart LR
A -->|SignalR planned/available| B
B --> C["SQLite cache<br/>chats/messages/sessions"]
B --> D["Local storage<br/>attachments/releases"]
B -->|HTTP internal| E["MAX worker<br/>Node + Playwright"]
E -->|persistent browser profile| F["web.max.ru"]
B -->|HTTP internal| E["MAX worker<br/>Python + PyMax"]
E -->|persistent PyMax session| F["MAX mobile API"]
B --> G["Caddy TLS<br/>qmax.kusoft.xyz"]
```
The server is the only public backend surface. The MAX worker stays inside the Docker network and is controlled through the API/admin page.
The server is the only public backend surface. The PyMax worker stays inside the Docker network and is controlled through the API/admin page.
## Security Rules
- Android pairs to the private server with `QMAX_PAIRING_CODE`.
- API access uses JWT access tokens and refresh tokens.
- MAX credentials and sessions live only on the Pi in Docker volumes.
- MAX session files live only on the Pi in Docker volumes.
- `.env`, service-account files, keystores and runtime data are ignored by git.
- 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
- MAX Web login is authorized on the Raspberry Pi worker.
- Chat list, message history, text sending and attachment sending are mapped through `worker/src/server.js`.
- PyMax login is authorized on the Raspberry Pi worker.
- 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`.
## Remaining Production Checks
- Keep selector drift monitoring for future MAX Web UI changes.
- Keep PyMax session-expiration monitoring active.
- Run an unlocked-device visual pass on the connected phone for every major UI change.
- Replace debug fallback signing with a real release key in `android/key.properties`.
+83 -7
View File
@@ -5,11 +5,13 @@ import base64
import mimetypes
import os
import pathlib
import shutil
import stat
import time
import traceback
import urllib.parse
from collections.abc import Awaitable, Callable
from contextlib import suppress
from datetime import UTC, datetime
from typing import Any
@@ -54,6 +56,35 @@ def normalize_phone(phone: str) -> str:
return phone.strip()
def is_expired_session_error(error: str | None) -> bool:
text = (error or "").lower()
return any(
marker in text
for marker in (
"fail_login_token",
"login.token",
"login token",
"authorize again",
"authorise again",
"please authorize",
"please authorise",
)
)
def archive_session_dir() -> 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}")
suffix = 1
while target.exists():
target = SESSION_DIR.with_name(f"{SESSION_DIR.name}.expired-{stamp}-{suffix}")
suffix += 1
shutil.move(str(SESSION_DIR), str(target))
return str(target)
def dump_model(obj: Any) -> dict[str, Any]:
if obj is None:
return {}
@@ -271,6 +302,13 @@ class DeferredCodeProvider:
self._future.set_result(code)
return True
def reset(self) -> None:
if self._future is not None and not self._future.done():
self._future.cancel()
self._future = None
self.waiting_for_code = False
self.requested_phone = None
class DeferredPasswordProvider:
def __init__(self) -> None:
@@ -295,6 +333,13 @@ class DeferredPasswordProvider:
self._future.set_result(password)
return True
def reset(self) -> None:
if self._future is not None and not self._future.done():
self._future.cancel()
self._future = None
self.waiting_for_password = False
self.hint = None
class PyMaxRuntime:
def __init__(self) -> None:
@@ -319,10 +364,15 @@ class PyMaxRuntime:
return "Password"
if self.code_provider.waiting_for_code:
return "Code"
if self.has_expired_session():
return "Expired"
if self.task is not None and not self.task.done():
return "Starting"
return "Idle"
def has_expired_session(self) -> bool:
return is_expired_session_error(self.last_error)
async def ensure_started(self, phone: str | None = None) -> None:
async with self.lock:
if self.task is not None and not self.task.done():
@@ -375,6 +425,8 @@ class PyMaxRuntime:
return False
async def get_client(self) -> Client:
if self.has_expired_session():
raise RuntimeError(self.last_error)
await self.ensure_started()
if not await self.wait_ready(15):
raise RuntimeError(self.last_error or f"PyMax is not ready; stage={self.login_stage()}")
@@ -382,10 +434,30 @@ class PyMaxRuntime:
return self.client
async def stop(self) -> None:
if self.client is not None:
await self.client.stop()
if self.task is not None:
self.task.cancel()
async with self.lock:
await self._stop_locked()
async def reset_session_for_login(self) -> None:
async with self.lock:
await self._stop_locked()
self.last_error = None
archive_session_dir()
async def _stop_locked(self) -> None:
client = self.client
task = self.task
self.client = None
self.task = None
self.ready.clear()
self.code_provider.reset()
self.password_provider.reset()
if client is not None:
with suppress(Exception):
await client.stop()
if task is not None and not task.done():
task.cancel()
with suppress(asyncio.CancelledError):
await task
runtime = PyMaxRuntime()
@@ -396,11 +468,12 @@ def json_response(data: Any, status: int = 200) -> web.Response:
def status_payload() -> dict[str, Any]:
stage = runtime.login_stage()
return {
"mode": "PyMax",
"isAuthorized": runtime.is_authorized,
"loginStage": runtime.login_stage(),
"status": "Ready" if runtime.is_authorized else runtime.login_stage(),
"loginStage": stage,
"status": "Ready" if runtime.is_authorized else ("SessionExpired" if stage == "Expired" else stage),
"url": None,
"title": runtime.last_title,
"lastError": runtime.last_error,
@@ -728,7 +801,8 @@ async def health(_request: web.Request) -> web.Response:
@route_errors
async def status(_request: web.Request) -> web.Response:
await runtime.ensure_started()
if not runtime.has_expired_session():
await runtime.ensure_started()
return json_response(status_payload())
@@ -736,6 +810,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"}:
await runtime.reset_session_for_login()
await runtime.ensure_started(phone)
return json_response(status_payload())
+3 -3
View File
@@ -23,14 +23,14 @@ if (Test-Path $staging) {
}
New-Item -ItemType Directory -Force -Path $staging | Out-Null
foreach ($item in @("server", "worker", "deploy", "Dockerfile.server", "QMax.slnx")) {
foreach ($item in @("server", "pymax-worker", "deploy", "Dockerfile.server", "QMax.slnx")) {
Copy-Item -LiteralPath (Join-Path $root $item) -Destination $staging -Recurse -Force
}
foreach ($path in @(
"server/QMax.Api/bin",
"server/QMax.Api/obj",
"worker/node_modules"
"pymax-worker/src/__pycache__"
)) {
$target = Join-Path $staging $path
if (Test-Path $target) {
@@ -40,7 +40,7 @@ foreach ($path in @(
Push-Location $staging
try {
tar -czf $archive server worker deploy Dockerfile.server QMax.slnx
tar -czf $archive server pymax-worker deploy Dockerfile.server QMax.slnx
}
finally {
Pop-Location
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@@ -6,6 +7,7 @@ using QMax.Api.Configuration;
using QMax.Api.Contracts;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Services;
@@ -18,6 +20,7 @@ public sealed class MaxController(
IMaxBridgeClient maxBridge,
MaxBridgeSyncService syncService,
QMaxDbContext db,
IHubContext<QMaxHub> hubContext,
IOptions<QMaxOptions> options) : ControllerBase
{
private readonly QMaxOptions _options = options.Value;
@@ -82,6 +85,7 @@ public sealed class MaxController(
state.LastError = status.LastError;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
}
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status)
@@ -15,7 +15,7 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
"max",
"MAX",
false,
"Mock mode is active. Switch QMax:MaxMode to Playwright for real web.max.ru.",
"Mock mode is active. Switch QMax:MaxMode to Worker and use qmax-pymax-worker for real MAX traffic.",
DateTimeOffset.UtcNow)
])
];
@@ -110,6 +110,6 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
private static string MockChatUrl(string externalChatId)
{
return $"https://web.max.ru/mock/{Uri.EscapeDataString(externalChatId)}";
return $"pymax://mock/{Uri.EscapeDataString(externalChatId)}";
}
}
@@ -40,7 +40,7 @@ public sealed class WorkerMaxBridgeClient(
public async Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken)
{
return await SendAsync<IReadOnlyList<MaxChatUpdate>>(HttpMethod.Get, "/updates", null, cancellationToken)
?? Array.Empty<MaxChatUpdate>();
?? throw new InvalidOperationException("MAX worker returned an empty updates response.");
}
public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
@@ -1,5 +1,8 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using QMax.Api.Contracts;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs;
@@ -29,10 +32,41 @@ public sealed class MaxBridgeSyncService(
catch (Exception ex)
{
logger.LogWarning(ex, "MAX sync failed.");
await PublishCurrentMaxStatusAsync(cancellationToken);
return 0;
}
}
private async Task PublishCurrentMaxStatusAsync(CancellationToken cancellationToken)
{
try
{
var status = await maxBridgeClient.GetStatusAsync(cancellationToken);
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var options = scope.ServiceProvider.GetRequiredService<IOptions<QMaxOptions>>().Value;
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
if (state is null)
{
state = new MaxAccountState { Id = 1 };
db.MaxAccountStates.Add(state);
}
state.PhoneNumber = options.MaxPhoneNumber;
state.Status = status.Status;
state.IsAuthorized = status.IsAuthorized;
state.LastUrl = status.Url;
state.LastError = status.LastError;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
}
catch (Exception statusError)
{
logger.LogDebug(statusError, "Unable to publish current MAX status after sync failure.");
}
}
public async Task<int> SyncChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(externalChatId))
@@ -1387,6 +1421,11 @@ public sealed class MaxBridgeSyncService(
};
}
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status)
{
return new MaxBridgeStatusDto(status.Mode, status.IsAuthorized, status.LoginStage, status.Status, status.Url, status.Title, status.LastError, status.UpdatedAt);
}
private sealed record LastKnownMessageSnapshot(
MessageDirection Direction,
string? Text,
+6 -3
View File
@@ -112,7 +112,7 @@ public sealed class ApiSmokeTests : IDisposable
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedChat = await db.Chats.AsNoTracking().SingleAsync(x => x.Id == chat.Id);
Assert.Equal("https://web.max.ru/mock/mock-direct", storedChat.WebUrl);
Assert.Equal("pymax://mock/mock-direct", storedChat.WebUrl);
}
[Fact]
@@ -1104,8 +1104,11 @@ public sealed class ApiSmokeTests : IDisposable
JsonOptions);
await AssertStatusAsync(HttpStatusCode.OK, messageResponse);
Assert.Equal(2, await ProcessOutboxAsync(factory));
Assert.Equal(["send:text", "clear"], bridge.OperationOrder);
Assert.True(await ProcessOutboxAsync(factory) >= 2);
var sendIndex = bridge.OperationOrder.IndexOf("send:text");
var clearIndex = bridge.OperationOrder.IndexOf("clear");
Assert.True(sendIndex >= 0, "Expected a text send operation.");
Assert.True(clearIndex > sendIndex, "Expected text send before pending clear-history action.");
}
[Fact]
-4
View File
@@ -1,4 +0,0 @@
node_modules
npm-debug.log
.env
data
-12
View File
@@ -1,12 +0,0 @@
FROM mcr.microsoft.com/playwright:v1.61.0-noble
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
COPY src ./src
ENV NODE_ENV=production
ENV PORT=3001
EXPOSE 3001
CMD ["npm", "start"]
-933
View File
@@ -1,933 +0,0 @@
{
"name": "qmax-max-worker",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qmax-max-worker",
"version": "0.1.0",
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"playwright": "1.61.0"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}
-15
View File
@@ -1,15 +0,0 @@
{
"name": "qmax-max-worker",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"playwright": "1.61.0"
}
}
-7052
View File
File diff suppressed because it is too large Load Diff