diff --git a/README.md b/README.md index 8cd3281..71d190c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt index 2a59cc0..0314c30 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -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, diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/realtime/QMaxRealtimeClient.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/realtime/QMaxRealtimeClient.kt index d5db72e..51c006a 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/realtime/QMaxRealtimeClient.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/realtime/QMaxRealtimeClient.kt @@ -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(payload.toString())) + } + }, + JsonElement::class.java + ) connection.on( "MessageCreated", { payload: JsonElement -> diff --git a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt index 11017b9..92f3dec 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt @@ -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 diff --git a/deploy/.env.example b/deploy/.env.example index d73ac43..b669ba2 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -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 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 77b91d3..7f2bb60 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9a3fb40..6eb2a93 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,31 +6,31 @@ flowchart LR A -->|SignalR planned/available| B B --> C["SQLite cache
chats/messages/sessions"] B --> D["Local storage
attachments/releases"] - B -->|HTTP internal| E["MAX worker
Node + Playwright"] - E -->|persistent browser profile| F["web.max.ru"] + B -->|HTTP internal| E["MAX worker
Python + PyMax"] + E -->|persistent PyMax session| F["MAX mobile API"] B --> G["Caddy TLS
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`. diff --git a/pymax-worker/src/server.py b/pymax-worker/src/server.py index 1745a0a..5835e7d 100644 --- a/pymax-worker/src/server.py +++ b/pymax-worker/src/server.py @@ -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()) diff --git a/scripts/deploy-rpi.ps1 b/scripts/deploy-rpi.ps1 index c625810..d3a5298 100644 --- a/scripts/deploy-rpi.ps1 +++ b/scripts/deploy-rpi.ps1 @@ -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 diff --git a/server/QMax.Api/Controllers/MaxController.cs b/server/QMax.Api/Controllers/MaxController.cs index cf959e2..94962c5 100644 --- a/server/QMax.Api/Controllers/MaxController.cs +++ b/server/QMax.Api/Controllers/MaxController.cs @@ -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 hubContext, IOptions 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) diff --git a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs index eb9965c..68399e2 100644 --- a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs @@ -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)}"; } } diff --git a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs index b319677..cbe5526 100644 --- a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs @@ -40,7 +40,7 @@ public sealed class WorkerMaxBridgeClient( public async Task> FetchUpdatesAsync(CancellationToken cancellationToken) { return await SendAsync>(HttpMethod.Get, "/updates", null, cancellationToken) - ?? Array.Empty(); + ?? throw new InvalidOperationException("MAX worker returned an empty updates response."); } public async Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index 76e720a..5a38544 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -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(); + var options = scope.ServiceProvider.GetRequiredService>().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 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, diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index 14e7ff6..cec14c2 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -112,7 +112,7 @@ public sealed class ApiSmokeTests : IDisposable using var scope = _factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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] diff --git a/worker/.dockerignore b/worker/.dockerignore deleted file mode 100644 index 69cfdb3..0000000 --- a/worker/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -npm-debug.log -.env -data diff --git a/worker/Dockerfile b/worker/Dockerfile deleted file mode 100644 index 320a364..0000000 --- a/worker/Dockerfile +++ /dev/null @@ -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"] diff --git a/worker/package-lock.json b/worker/package-lock.json deleted file mode 100644 index 149f7bd..0000000 --- a/worker/package-lock.json +++ /dev/null @@ -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" - } - } -} diff --git a/worker/package.json b/worker/package.json deleted file mode 100644 index 68f8c4b..0000000 --- a/worker/package.json +++ /dev/null @@ -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" - } -} diff --git a/worker/src/server.js b/worker/src/server.js deleted file mode 100644 index 079a8b8..0000000 --- a/worker/src/server.js +++ /dev/null @@ -1,7052 +0,0 @@ -import cors from "cors"; -import { createHash } from "crypto"; -import express from "express"; -import fs from "fs/promises"; -import { chromium } from "playwright"; - -const port = Number(process.env.PORT || 3001); -const maxBaseUrl = process.env.MAX_BASE_URL || "https://web.max.ru/"; -const userDataDir = process.env.MAX_USER_DATA_DIR || "/data/max-profile"; -const headless = (process.env.MAX_HEADLESS || "true").toLowerCase() !== "false"; -const domDownloadPrefix = "qmax-dom-download:"; -const domAudioPrefix = "qmax-dom-audio:"; -const domStickerPrefix = "qmax-dom-sticker:"; -const domEmojiPrefix = "qmax-dom-emoji:"; - -let context; -let contextInitialization; -let page; -let lastError = null; -const pagesByRole = new Map(); -const pageOperations = new Map(); -const networkLog = []; - -const app = express(); -app.use(cors()); -app.use(express.json({ limit: "10mb" })); - -function normalizeMaxChatUrl(value) { - const raw = String(value || "").trim(); - if (!raw) return ""; - - try { - const url = new URL(raw, maxBaseUrl); - const base = new URL(maxBaseUrl); - if (url.origin !== base.origin) { - return ""; - } - - return url.href; - } catch { - return ""; - } -} - -async function getCurrentChatUrl(p) { - const current = normalizeMaxChatUrl(p.url()); - if (!current) { - return null; - } - - const base = new URL(maxBaseUrl).href; - return current === base ? null : current; -} - -app.get("/health", (_req, res) => { - res.json({ status: "ok", serverTime: new Date().toISOString() }); -}); - -app.get("/status", async (_req, res) => { - res.json(await withPageLock(() => status())); -}); - -app.post("/login/start", async (req, res) => { - try { - const phoneNumber = String(req.body?.phoneNumber || "").trim(); - if (!phoneNumber) { - return res.status(400).json({ error: "phoneNumber is required" }); - } - - const result = await withPageLock(async () => { - const p = await ensurePage(); - await p.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); - await p.waitForTimeout(1500); - await openPhoneLogin(p); - const filled = await fillFirst(p, normalizePhoneNumberForMax(phoneNumber), [ - "input[type='tel']", - "input[autocomplete='tel']", - "input[name*='phone' i]", - "input[placeholder*='тел' i]", - "input" - ]); - if (filled) { - await clickLikelySubmitFixed(p); - await p.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {}); - await p.waitForTimeout(1500); - } - - return await status("LoginStarted"); - }); - - res.json(result); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.post("/login/code", async (req, res) => { - try { - const code = normalizeLoginCode(req.body?.code); - if (!code) { - return res.status(400).json({ error: "code is required" }); - } - - const result = await withPageLock(async () => { - const p = await ensurePage(); - const waitMs = clampNumber(req.body?.waitMs ?? req.query?.waitMs, 0, 120000, 45000); - const codeScreenReady = await waitForLoginStage(p, "CodeEntry", waitMs); - if (!codeScreenReady) { - const currentStatus = await status("CodeScreenNotReady"); - currentStatus.lastError = "MAX is not showing the SMS code entry screen."; - return currentStatus; - } - - const filled = await fillLoginCode(p, code); - if (!filled) { - await p.keyboard.insertText(code); - } - await clickLikelySubmitFixed(p); - await p.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {}); - await p.waitForTimeout(2500); - return await status("CodeSubmitted"); - }); - - res.json(result); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.get("/snapshot", async (_req, res) => { - try { - const snapshot = await withPageLock(async () => { - const p = await ensurePage(); - const screenshot = await p.screenshot({ fullPage: true, type: "png" }); - return { - url: p.url(), - title: await safeTitle(p), - bodyText: await safeBodyText(p), - screenshotPngBase64: screenshot.toString("base64"), - capturedAt: new Date().toISOString() - }; - }); - res.json(snapshot); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.post("/browser/click", async (req, res) => { - try { - const result = await withPageLock(async () => { - const p = await ensurePage(); - await p.mouse.click(Number(req.body?.x), Number(req.body?.y)); - await p.waitForTimeout(500); - return await status("Clicked"); - }); - res.json(result); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.post("/browser/type", async (req, res) => { - try { - const result = await withPageLock(async () => { - const p = await ensurePage(); - await p.keyboard.insertText(String(req.body?.text || "")); - return await status("Typed"); - }); - res.json(result); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.post("/browser/press", async (req, res) => { - try { - const result = await withPageLock(async () => { - const p = await ensurePage(); - await p.keyboard.press(String(req.body?.key || "Enter")); - await p.waitForTimeout(500); - return await status("Pressed"); - }); - res.json(result); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.get("/inspect/indexeddb", async (_req, res) => { - try { - const databases = await withPageLock(async () => { - const p = await ensurePage(); - return await p.evaluate(async () => { - if (!indexedDB.databases) return []; - const dbs = await indexedDB.databases(); - return dbs.map((db) => ({ name: db.name, version: db.version })); - }); - }); - res.json({ databases }); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.get("/inspect/indexeddb/sample", async (req, res) => { - try { - const sampleLimit = clampNumber(req.query?.sampleLimit, 1, 10, 3); - const dbLimit = clampNumber(req.query?.dbLimit, 1, 20, 10); - const storeLimit = clampNumber(req.query?.storeLimit, 1, 80, 40); - const stringLimit = clampNumber(req.query?.stringLimit, 16, 512, 96); - const result = await withPageLock(async () => { - const p = await ensurePage(); - return await inspectIndexedDbSamples(p, { sampleLimit, dbLimit, storeLimit, stringLimit }); - }); - res.json(result); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.get("/inspect/dom", async (req, res) => { - try { - res.json(await withPageLock(async () => { - const role = String(req.query?.role || "default").trim() || "default"; - const p = await ensurePage(role); - return await inspectDom(p); - }, String(req.query?.role || "default").trim() || "default")); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.get("/inspect/storage", async (_req, res) => { - try { - res.json(await withPageLock(async () => { - const p = await ensurePage(); - return await inspectStorage(p); - })); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.get("/inspect/network", async (_req, res) => { - res.json({ - capturedAt: new Date().toISOString(), - entries: networkLog.slice(-200) - }); -}); - -app.get("/inspect/chat-urls", async (req, res) => { - try { - const limit = clampNumber(req.query?.limit, 1, 800, 600); - const result = await withPageLock(async () => { - const p = await ensurePage("incoming"); - const currentStatus = await status(null, p); - if (!currentStatus.isAuthorized) { - return { chats: [], error: "MAX is not authorized." }; - } - - return { chats: await collectSidebarChatUrls(p, limit), error: null }; - }, "incoming"); - res.json(result); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.get("/media/fetch", async (req, res) => { - try { - const mediaUrl = String(req.query?.url || "").trim(); - if (!isAllowedMediaUrl(mediaUrl)) { - return res.status(400).json({ error: "Unsupported media URL." }); - } - - const media = await withPageLock(async () => { - const p = await ensurePage("incoming"); - return await fetchMediaThroughMaxSession(p, mediaUrl); - }, "incoming"); - - res.setHeader("Content-Type", media.contentType || "application/octet-stream"); - res.setHeader("Content-Length", String(media.buffer.length)); - res.send(media.buffer); - } catch (error) { - res.status(502).json(errorStatus(error)); - } -}); - -app.get("/updates", async (_req, res) => { - try { - const result = await withPageLock(async () => { - const p = await ensurePage("incoming"); - const currentStatus = await status(null, p); - if (!currentStatus.isAuthorized) { - return []; - } - - await selectSidebarFilter(p, ["\u0412\u0441\u0435", "All"]); - await clearSidebarSearch(p); - await resetSidebarListToTop(p); - const allUpdates = await extractUpdates(p); - const channelUpdates = await extractChannelUpdates(p, allUpdates); - await selectSidebarFilter(p, ["\u0412\u0441\u0435", "All"]); - await resetSidebarListToTop(p); - return mergeChatUpdates([...allUpdates, ...channelUpdates]); - }, "incoming"); - res.json(result); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.post("/chat/history", async (req, res) => { - try { - const externalChatId = String(req.body?.externalChatId || "").trim(); - const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); - const result = await withPageLock(async () => { - const p = await ensurePage("incoming"); - const currentStatus = await status(null, p); - if (!currentStatus.isAuthorized) { - return null; - } - - if (externalChatId) { - const opened = await ensureDomChatOpen(p, externalChatId, 1000, false, chatUrl); - if (!opened) { - return null; - } - } - - await scrollOpenChatToLatest(p); - const update = await extractOpenChatHistory(p, externalChatId); - return { ...update, chatUrl: await getCurrentChatUrl(p) }; - }, "incoming"); - - if (!result) { - return res.status(409).json({ error: "MAX is not authorized." }); - } - - res.json(result); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.post("/chat/resolve-url", async (req, res) => { - try { - const externalChatId = String(req.body?.externalChatId || "").trim(); - if (!externalChatId) { - return res.status(400).json({ success: false, chatUrl: null, error: "externalChatId is required." }); - } - - const result = await withPageLock(async () => { - const p = await ensurePage("commands"); - const currentStatus = await status(null, p); - if (!currentStatus.isAuthorized) { - return { success: false, chatUrl: null, error: "MAX is not authorized." }; - } - - const chatUrl = await resolveDomChatUrl(p, externalChatId); - return { - success: Boolean(chatUrl), - chatUrl: chatUrl || null, - error: chatUrl ? null : "MAX chat URL was not found." - }; - }, "commands"); - - res.json(result); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.post("/chat/clear-history", async (req, res) => { - try { - const externalChatId = String(req.body?.externalChatId || "").trim(); - const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); - if (!externalChatId) { - return res.json({ success: false, error: "externalChatId is required." }); - } - - const result = await withPageLock(async () => { - const p = await ensurePage("commands"); - return await clearChatHistoryInMax(p, externalChatId, chatUrl); - }, "commands"); - res.json(result); - } catch (error) { - res.json({ success: false, error: String(error?.message || error) }); - } -}); - -app.post("/chat/delete", async (req, res) => { - try { - const externalChatId = String(req.body?.externalChatId || "").trim(); - if (!externalChatId) { - return res.json({ success: false, error: "externalChatId is required." }); - } - - res.json({ - success: false, - error: "Chat deletion is disabled because MAX does not remove chats from the web client." - }); - } catch (error) { - res.json({ success: false, error: String(error?.message || error) }); - } -}); - -app.post("/chat/menu-probe", async (req, res) => { - try { - const externalChatId = String(req.body?.externalChatId || "").trim(); - const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); - const labels = Array.isArray(req.body?.labels) && req.body.labels.length > 0 - ? req.body.labels.map((label) => String(label || "")) - : ["\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442", "\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "delete chat", "delete conversation", "remove chat"]; - const probeAction = Boolean(req.body?.probeAction); - if (!externalChatId) { - return res.json({ success: false, error: "externalChatId is required.", chatUrl: null }); - } - - const result = await withPageLock(async () => { - const p = await ensurePage("commands"); - const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, labels); - const actionClicked = menuOpened && probeAction - ? await clickActionByTextWithRetry(p, labels, 1500) - : false; - if (actionClicked) { - await p.waitForTimeout(700); - } - const visibleActions = actionClicked ? await readVisibleActionTexts(p) : []; - await p.keyboard.press("Escape").catch(() => {}); - await clearSidebarSearch(p); - return { - success: menuOpened, - error: menuOpened ? null : "MAX chat menu was not opened.", - chatUrl: await getCurrentChatUrl(p), - actionClicked, - visibleActions - }; - }, "commands"); - res.json(result); - } catch (error) { - res.json({ success: false, error: String(error?.message || error), chatUrl: null }); - } -}); - -app.post("/chat/clear-composer", async (req, res) => { - try { - const externalChatId = String(req.body?.externalChatId || "").trim(); - const result = await withPageLock(async () => { - const p = await ensurePage("outgoing"); - const currentStatus = await status(null, p); - if (!currentStatus.isAuthorized) { - return { success: false, error: "MAX is not authorized.", composerText: null }; - } - - if (externalChatId) { - const opened = await ensureDomChatOpen(p, externalChatId, 2000, true); - if (!opened) { - return { success: false, error: "MAX chat was not found or did not open.", composerText: null }; - } - } - - const success = await clearComposerInput(p); - return { success, error: success ? null : "MAX composer was not cleared.", composerText: await readComposerText(p) }; - }, "outgoing"); - - res.json(result); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.post("/chat/draft-text", async (req, res) => { - try { - const externalChatId = String(req.body?.externalChatId || "").trim(); - const text = String(req.body?.text || ""); - if (!text) { - return res.json({ success: false, error: "Text is empty.", composerText: null }); - } - - const result = await withPageLock(async () => { - const p = await ensurePage("outgoing"); - const currentStatus = await status(null, p); - if (!currentStatus.isAuthorized) { - return { success: false, error: "MAX is not authorized.", composerText: null }; - } - - if (externalChatId) { - const opened = await ensureDomChatOpen(p, externalChatId, 2000, true); - if (!opened) { - return { success: false, error: "MAX chat was not found or did not open.", composerText: null }; - } - } - - const success = await fillComposerText(p, text, true); - return { success, error: success ? null : "MAX composer was not filled.", composerText: await readComposerText(p) }; - }, "outgoing"); - - res.json(result); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.post("/chat/presence", async (req, res) => { - try { - const externalChatId = String(req.body?.externalChatId || "").trim(); - const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); - const result = await withPageLock(async () => { - const p = await ensurePage("incoming"); - const currentStatus = await status(null, p); - if (!currentStatus.isAuthorized) { - return { isTyping: false, statusText: null, updatedAt: new Date().toISOString() }; - } - - if (externalChatId) { - await ensureDomChatOpen(p, externalChatId, 600, false, chatUrl); - } - - return await extractOpenChatPresence(p); - }, "incoming"); - - res.json(result); - } catch (error) { - res.status(500).json(errorStatus(error)); - } -}); - -app.post("/send/text", async (req, res) => { - try { - const externalChatId = String(req.body?.externalChatId || "").trim(); - const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); - const text = String(req.body?.text || ""); - if (!text) { - return res.json({ success: false, externalMessageId: null, error: "Text is empty." }); - } - - const result = await withPageLock(async () => { - const p = await ensurePage("outgoing"); - - if (externalChatId) { - const opened = await ensureDomChatOpen(p, externalChatId, 8000, true, chatUrl); - if (!opened) { - return { success: false, externalMessageId: null, error: "MAX chat was not found or did not open.", chatUrl: null }; - } - } - - const externalMessageId = await sendTextAndConfirm(p, externalChatId, text); - if (!externalMessageId) { - return { success: false, externalMessageId: null, error: "MAX did not confirm the outgoing text message.", chatUrl: await getCurrentChatUrl(p) }; - } - return { success: true, externalMessageId, error: null, chatUrl: await getCurrentChatUrl(p) }; - }, "outgoing"); - res.json(result); - } catch (error) { - res.json({ success: false, externalMessageId: null, error: String(error?.message || error) }); - } -}); - -app.post("/send/attachment", async (req, res) => { - try { - const externalChatId = String(req.body?.externalChatId || "").trim(); - const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); - const path = String(req.body?.path || "").trim(); - const caption = String(req.body?.caption || ""); - if (!path) { - return res.json({ success: false, externalMessageId: null, error: "Attachment path is empty." }); - } - - const result = await withPageLock(async () => { - const p = await ensurePage("outgoing"); - if (externalChatId) { - const opened = await ensureDomChatOpen(p, externalChatId, 8000, true, chatUrl); - if (!opened) { - return { success: false, externalMessageId: null, error: "MAX chat was not found or did not open.", chatUrl: null }; - } - } - - const externalMessageId = await sendAttachmentAndConfirm(p, externalChatId, path, caption); - if (!externalMessageId) { - return { success: false, externalMessageId: null, error: "MAX did not confirm the outgoing attachment.", chatUrl: await getCurrentChatUrl(p) }; - } - - return { success: true, externalMessageId, error: null, chatUrl: await getCurrentChatUrl(p) }; - }, "outgoing"); - - res.json(result); - } catch (error) { - res.json({ success: false, externalMessageId: null, error: String(error?.message || error) }); - } -}); - -app.post("/message/edit", async (req, res) => { - try { - const externalChatId = String(req.body?.externalChatId || "").trim(); - const externalMessageId = String(req.body?.externalMessageId || "").trim(); - const currentText = String(req.body?.currentText || ""); - const text = String(req.body?.text || "").trim(); - if (!externalChatId || !externalMessageId || !text) { - return res.json({ success: false, error: "externalChatId, externalMessageId and text are required." }); - } - - const result = await withPageLock(async () => { - const p = await ensurePage("outgoing"); - return await editMessageInMax(p, externalChatId, externalMessageId, currentText, text); - }, "outgoing"); - res.json(result); - } catch (error) { - res.json({ success: false, error: String(error?.message || error) }); - } -}); - -app.post("/message/delete", async (req, res) => { - try { - const externalChatId = String(req.body?.externalChatId || "").trim(); - const externalMessageId = String(req.body?.externalMessageId || "").trim(); - const currentText = String(req.body?.currentText || ""); - if (!externalChatId || !externalMessageId) { - return res.json({ success: false, error: "externalChatId and externalMessageId are required." }); - } - - const result = await withPageLock(async () => { - const p = await ensurePage("outgoing"); - return await deleteMessageInMax(p, externalChatId, externalMessageId, currentText); - }, "outgoing"); - res.json(result); - } catch (error) { - res.json({ success: false, error: String(error?.message || error) }); - } -}); - -app.post("/message/reaction", async (req, res) => { - try { - const externalChatId = String(req.body?.externalChatId || "").trim(); - const externalMessageId = String(req.body?.externalMessageId || "").trim(); - const currentText = String(req.body?.currentText || ""); - const emoji = String(req.body?.emoji || "").trim(); - if (!externalChatId || !externalMessageId || !emoji) { - return res.json({ success: false, error: "externalChatId, externalMessageId and emoji are required." }); - } - - const result = await withPageLock(async () => { - const p = await ensurePage("outgoing"); - return await setMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji); - }, "outgoing"); - res.json(result); - } catch (error) { - res.json({ success: false, error: String(error?.message || error) }); - } -}); - -app.delete("/message/reaction", async (req, res) => { - try { - const externalChatId = String(req.body?.externalChatId || "").trim(); - const externalMessageId = String(req.body?.externalMessageId || "").trim(); - const currentText = String(req.body?.currentText || ""); - const emoji = String(req.body?.emoji || "").trim(); - if (!externalChatId || !externalMessageId || !emoji) { - return res.json({ success: false, error: "externalChatId, externalMessageId and emoji are required." }); - } - - const result = await withPageLock(async () => { - const p = await ensurePage("outgoing"); - return await clearMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji); - }, "outgoing"); - res.json(result); - } catch (error) { - res.json({ success: false, error: String(error?.message || error) }); - } -}); - -app.listen(port, () => { - console.log(`QMAX MAX worker listening on ${port}, headless=${headless}`); -}); - -async function ensurePage(role = "default") { - const normalizedRole = String(role || "default"); - const existing = pagesByRole.get(normalizedRole); - if (existing && !existing.isClosed()) { - await waitForRolePageReady(existing, normalizedRole); - return existing; - } - - await ensureContext(); - - if (normalizedRole === "default") { - if (!page || page.isClosed()) { - page = context.pages().find((candidate) => !candidate.isClosed()) || await context.newPage(); - pagesByRole.set("default", page); - attachNetworkCapture(page); - if (!page.url() || page.url() === "about:blank") { - await page.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); - } - } - - return page; - } - - const rolePage = await context.newPage(); - pagesByRole.set(normalizedRole, rolePage); - attachNetworkCapture(rolePage); - await rolePage.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); - await waitForRolePageReady(rolePage, normalizedRole); - return rolePage; -} - -async function waitForRolePageReady(rolePage, role) { - if (role === "default") { - return; - } - - await rolePage.waitForLoadState("domcontentloaded", { timeout: 15000 }).catch(() => {}); - await waitForLoginStage(rolePage, "Authorized", 15000).catch(() => false); -} - -async function ensureContext() { - if (context) return; - if (!contextInitialization) { - contextInitialization = (async () => { - await cleanupChromiumProfileLocks(userDataDir); - context = await chromium.launchPersistentContext(userDataDir, { - headless, - acceptDownloads: true, - locale: "ru-RU", - timezoneId: process.env.MAX_TIMEZONE_ID || "Europe/Moscow", - viewport: { width: 1280, height: 900 }, - args: ["--disable-dev-shm-usage", "--no-sandbox"] - }); - await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: maxBaseUrl }).catch(() => {}); - page = context.pages()[0] || await context.newPage(); - pagesByRole.set("default", page); - attachNetworkCapture(page); - if (!page.url() || page.url() === "about:blank") { - await page.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); - } - })().catch((error) => { - contextInitialization = null; - throw error; - }); - } - - await contextInitialization; -} - -async function withPageLock(operation, role = "default") { - const normalizedRole = String(role || "default"); - const previousOperation = pageOperations.get(normalizedRole) || Promise.resolve(); - let release; - const nextOperation = new Promise((resolve) => { - release = resolve; - }); - pageOperations.set(normalizedRole, nextOperation); - - await previousOperation.catch(() => {}); - try { - return await operation(); - } finally { - release(); - } -} - -async function status(overrideStatus = null, statusPage = null) { - try { - const p = statusPage || await ensurePage(); - const title = await safeTitle(p); - const url = p.url(); - const bodyText = await safeBodyText(p); - const loginStage = detectLoginStageFromText(url, title, bodyText); - return { - mode: "Worker", - isAuthorized: loginStage === "Authorized", - loginStage, - status: overrideStatus || "BrowserReady", - url, - title, - lastError, - pageRoles: getOpenPageRoles(), - updatedAt: new Date().toISOString() - }; - } catch (error) { - return errorStatus(error); - } -} - -function errorStatus(error) { - lastError = String(error?.message || error); - const fallbackPage = firstKnownPage(); - return { - mode: "Worker", - isAuthorized: false, - status: "WorkerError", - url: fallbackPage?.url?.() || null, - title: null, - lastError, - pageRoles: getOpenPageRoles(), - updatedAt: new Date().toISOString() - }; -} - -function firstKnownPage() { - return page || - pagesByRole.get("default") || - pagesByRole.get("incoming") || - pagesByRole.get("outgoing") || - pagesByRole.get("commands") || - null; -} - -function getOpenPageRoles() { - return Array.from(pagesByRole.entries()) - .filter(([, rolePage]) => rolePage && !rolePage.isClosed()) - .map(([roleName]) => roleName) - .sort(); -} - -function isAllowedMediaUrl(value) { - const raw = String(value || "").trim(); - if (!raw) return false; - if (isDomDownloadUrl(raw) || isDomAudioUrl(raw) || isDomStickerUrl(raw) || isDomEmojiUrl(raw)) return true; - if (raw.startsWith("blob:")) { - try { - const inner = new URL(raw.slice("blob:".length)); - return inner.protocol === "https:" && !isPrivateHost(inner.hostname); - } catch { - return false; - } - } - - try { - const parsed = new URL(raw); - return ["http:", "https:"].includes(parsed.protocol) && !isPrivateHost(parsed.hostname); - } catch { - return false; - } -} - -function isDomDownloadUrl(value) { - return String(value || "").startsWith(domDownloadPrefix); -} - -function isDomAudioUrl(value) { - return String(value || "").startsWith(domAudioPrefix); -} - -function isDomStickerUrl(value) { - return String(value || "").startsWith(domStickerPrefix); -} - -function isDomEmojiUrl(value) { - return String(value || "").startsWith(domEmojiPrefix); -} - -function parseDomDownloadUrl(value) { - const raw = String(value || ""); - if (!isDomDownloadUrl(raw)) { - return null; - } - - try { - return JSON.parse(decodeURIComponent(raw.slice(domDownloadPrefix.length))); - } catch { - return null; - } -} - -function parseDomAudioUrl(value) { - const raw = String(value || ""); - if (!isDomAudioUrl(raw)) { - return null; - } - - try { - return JSON.parse(decodeURIComponent(raw.slice(domAudioPrefix.length))); - } catch { - return null; - } -} - -function parseDomStickerUrl(value) { - const raw = String(value || ""); - if (!isDomStickerUrl(raw)) { - return null; - } - - try { - return JSON.parse(decodeURIComponent(raw.slice(domStickerPrefix.length))); - } catch { - return null; - } -} - -function parseDomEmojiUrl(value) { - const raw = String(value || ""); - if (!isDomEmojiUrl(raw)) { - return null; - } - - try { - return JSON.parse(decodeURIComponent(raw.slice(domEmojiPrefix.length))); - } catch { - return null; - } -} - -function domMediaPrefixForUrl(value) { - const raw = String(value || ""); - if (isDomDownloadUrl(raw)) return domDownloadPrefix; - if (isDomAudioUrl(raw)) return domAudioPrefix; - if (isDomStickerUrl(raw)) return domStickerPrefix; - if (isDomEmojiUrl(raw)) return domEmojiPrefix; - return null; -} - -function parseDomMediaUrl(value) { - const prefix = domMediaPrefixForUrl(value); - if (!prefix) { - return null; - } - - try { - return JSON.parse(decodeURIComponent(String(value).slice(prefix.length))); - } catch { - return null; - } -} - -function attachDomMediaChatContext(value, externalChatId, chatUrl) { - const prefix = domMediaPrefixForUrl(value); - if (!prefix) { - return value; - } - - const parsed = parseDomMediaUrl(value); - if (!parsed) { - return value; - } - - const next = { - ...parsed, - externalChatId: parsed.externalChatId || externalChatId || null, - chatUrl: normalizeMaxChatUrl(parsed.chatUrl) || normalizeMaxChatUrl(chatUrl) || null - }; - return `${prefix}${encodeURIComponent(JSON.stringify(next))}`; -} - -async function ensureDomMediaChatContext(p, request) { - const externalChatId = String(request?.externalChatId || "").trim(); - const chatUrl = normalizeMaxChatUrl(request?.chatUrl); - if (!externalChatId && !chatUrl) { - return; - } - - await ensureDomChatOpen(p, externalChatId, 5000, false, chatUrl); -} - -function isPrivateHost(hostname) { - const host = String(hostname || "").toLowerCase(); - if (!host || host === "localhost" || host.endsWith(".localhost")) return true; - if (/^(127|10)\./.test(host)) return true; - if (/^192\.168\./.test(host)) return true; - if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true; - if (/^(0|169\.254)\./.test(host)) return true; - if (host === "::1" || host.startsWith("fc") || host.startsWith("fd")) return true; - return false; -} - -async function fetchMediaThroughMaxSession(p, mediaUrl) { - if (isDomDownloadUrl(mediaUrl)) { - const request = parseDomDownloadUrl(mediaUrl); - await ensureDomMediaChatContext(p, request); - return await downloadDomAttachmentThroughClick(p, request); - } - - if (isDomAudioUrl(mediaUrl)) { - const request = parseDomAudioUrl(mediaUrl); - await ensureDomMediaChatContext(p, request); - return await downloadDomAudioThroughPlay(p, request); - } - - if (isDomStickerUrl(mediaUrl)) { - const request = parseDomStickerUrl(mediaUrl); - await ensureDomMediaChatContext(p, request); - return await downloadDomStickerThroughScreenshot(p, request); - } - - if (isDomEmojiUrl(mediaUrl)) { - const request = parseDomEmojiUrl(mediaUrl); - await ensureDomMediaChatContext(p, request); - return await downloadDomStickerThroughScreenshot(p, { - ...request, - selector: "[data-testid*='emoji' i], [class*='emoji' i], [class*='lottie' i], [class*='emoticon' i], canvas, svg", - kindLabel: "MAX emoji canvas", - acceptSmall: true, - maxClipSize: 256, - requireTimeMatch: true - }); - } - - if (mediaUrl.startsWith("blob:")) { - const result = await p.evaluate(async (url) => { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Blob fetch failed: ${response.status}`); - } - const arrayBuffer = await response.arrayBuffer(); - const bytes = Array.from(new Uint8Array(arrayBuffer)); - return { - contentType: response.headers.get("content-type") || "application/octet-stream", - bytes - }; - }, mediaUrl); - - return { - contentType: result.contentType, - buffer: Buffer.from(result.bytes) - }; - } - - const response = await context.request.get(mediaUrl, { timeout: 60000 }); - if (!response.ok()) { - throw new Error(`Media fetch failed: ${response.status()} ${response.statusText()}`); - } - - const headers = response.headers(); - return { - contentType: String(headers["content-type"] || "application/octet-stream").split(";")[0], - buffer: await response.body() - }; -} - -async function downloadDomAttachmentThroughClick(p, request) { - const fileName = String(request?.fileName || "").trim(); - const label = String(request?.label || fileName).trim(); - if (!fileName && !label) { - throw new Error("DOM download request does not include a file label."); - } - - const downloadPromise = p.waitForEvent("download", { timeout: 20000 }); - const clicked = await p.evaluate(({ fileName, label }) => { - const cleanText = (value, limit = 500) => String(value || "") - .replace(/\u00a0/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, limit); - const isVisible = (element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const expectedFile = cleanText(fileName, 160).toLowerCase(); - const expectedLabel = cleanText(label, 300).toLowerCase(); - const buttons = Array.from(document.querySelectorAll("button,[role='button']")) - .filter((node) => node instanceof HTMLElement && isVisible(node)); - const match = buttons.find((button) => { - const text = cleanText(button.innerText || button.textContent || button.getAttribute("aria-label"), 300).toLowerCase(); - if (!text) return false; - if (expectedFile && text.includes(expectedFile)) return true; - return expectedLabel && (text === expectedLabel || text.includes(expectedLabel)); - }); - if (!match) { - return false; - } - - match.click(); - return true; - }, { fileName, label }).catch(() => false); - - if (!clicked) { - downloadPromise.catch(() => null); - throw new Error(`MAX file download button was not found for ${fileName || label}.`); - } - - const download = await downloadPromise; - const path = await download.path(); - if (!path) { - throw new Error("MAX file download did not produce a local file."); - } - - const buffer = await fs.readFile(path); - await download.delete().catch(() => {}); - return { - contentType: contentTypeFromFileName(download.suggestedFilename() || fileName), - buffer - }; -} - -async function downloadDomAudioThroughPlay(p, request) { - const duration = String(request?.duration || "").trim(); - const timeText = String(request?.timeText || "").trim(); - const label = String(request?.label || duration || timeText || "voice").trim(); - const point = await p.evaluate(({ duration, timeText, label }) => { - const cleanText = (value, limit = 500) => String(value || "") - .replace(/\u00a0/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, limit); - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const roots = Array.from(document.querySelectorAll("[class*='attachAudio' i], [class*='voice' i], [class*='audio' i]")) - .filter((node) => node instanceof HTMLElement && isVisible(node)) - .map((root) => { - const wrapper = root.closest("[class*='messageWrapper' i], [role='listitem'], [class*='item' i]") || root; - const text = cleanText(`${root.innerText || root.textContent || ""} ${wrapper.innerText || wrapper.textContent || ""}`, 1000); - const semantic = [ - root.className, - root.getAttribute("aria-label"), - root.getAttribute("title"), - text - ].filter(Boolean).join(" ").toLowerCase(); - let score = /attachaudio|audio|voice|\u0433\u043e\u043b\u043e\u0441|\u0430\u0443\u0434\u0438\u043e/i.test(semantic) ? 4 : 0; - if (duration && text.includes(duration)) score += 8; - if (timeText && text.includes(timeText)) score += 3; - if (label && text.toLowerCase().includes(label.toLowerCase())) score += 1; - const rect = root.getBoundingClientRect(); - return { root, score, bottom: rect.bottom }; - }) - .filter((item) => item.score > 0) - .sort((a, b) => b.score - a.score || b.bottom - a.bottom); - - const root = roots[0]?.root; - if (!root) { - return null; - } - - const clickable = root.querySelector("button,[role='button']") || root; - const rect = clickable.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) { - return null; - } - - const rootRect = root.getBoundingClientRect(); - const leftPlayX = rootRect.left + Math.min(28, Math.max(12, rootRect.width * 0.12)); - return { - x: Math.round(clickable === root ? leftPlayX : rect.left + rect.width / 2), - y: Math.round(rect.top + rect.height / 2) - }; - }, { duration, timeText, label }).catch(() => null); - - if (!point) { - throw new Error(`MAX voice player was not found for ${label}.`); - } - - const waitForVoiceResponse = () => p.waitForResponse((response) => { - try { - const status = response.status(); - if (status !== 200 && status !== 206) { - return false; - } - - const url = response.url(); - if (/\/_app\/immutable\/assets\//i.test(url)) { - return false; - } - - const request = response.request(); - const resourceType = request.resourceType(); - const headers = response.headers(); - const contentType = String(headers["content-type"] || "").split(";")[0].toLowerCase(); - if (contentType.startsWith("audio/")) { - return true; - } - - return resourceType === "media" && - /\.(ogg|opus|m4a|aac|mp3|wav|flac|oga)(\?|#|$)/i.test(url); - } catch { - return false; - } - }, { timeout: 8000 }).catch(() => null); - - for (let attempt = 0; attempt < 3; attempt++) { - const responsePromise = waitForVoiceResponse(); - await p.mouse.click(point.x, point.y); - const response = await responsePromise; - if (response) { - const headers = response.headers(); - const buffer = await response.body(); - if (buffer.length > 0) { - return { - contentType: String(headers["content-type"] || "audio/ogg").split(";")[0], - buffer - }; - } - } - - await p.waitForTimeout(600); - } - - const embedded = await p.evaluate(async ({ duration, timeText }) => { - const cleanText = (value, limit = 500) => String(value || "") - .replace(/\u00a0/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, limit); - const candidates = Array.from(document.querySelectorAll("audio, video")) - .map((media) => { - const wrapper = media.closest("[class*='messageWrapper' i], [role='listitem'], [class*='item' i]") || media; - const text = cleanText(wrapper.innerText || wrapper.textContent || "", 1000); - const src = media.currentSrc || media.src || media.querySelector?.("source")?.src || ""; - let score = src ? 1 : 0; - if (duration && text.includes(duration)) score += 8; - if (timeText && text.includes(timeText)) score += 3; - return { src, score }; - }) - .filter((item) => item.src && item.score > 0) - .sort((a, b) => b.score - a.score); - const src = candidates[0]?.src; - if (!src || !src.startsWith("blob:")) { - return null; - } - - const response = await fetch(src); - if (!response.ok) { - return null; - } - - const arrayBuffer = await response.arrayBuffer(); - return { - contentType: response.headers.get("content-type") || "audio/ogg", - bytes: Array.from(new Uint8Array(arrayBuffer)) - }; - }, { duration, timeText }).catch(() => null); - - if (embedded?.bytes?.length) { - return { - contentType: embedded.contentType || "audio/ogg", - buffer: Buffer.from(embedded.bytes) - }; - } - - throw new Error(`MAX voice media response was not captured for ${label}.`); -} - -async function downloadDomStickerThroughScreenshot(p, request) { - const testId = String(request?.testId || "").trim(); - const timeText = String(request?.timeText || "").trim(); - const fallbackSelector = String(request?.selector || "[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i]"); - const kindLabel = String(request?.kindLabel || "MAX sticker canvas"); - const selectorForTestId = (value) => `[data-testid="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`; - const visualHandleFor = async (handle) => { - const visual = await handle.evaluateHandle((node) => { - const element = node instanceof Element ? node : node?.parentElement; - if (!element) return null; - - const candidates = [ - ...(element.matches("canvas,img,image,svg") ? [element] : []), - ...Array.from(element.querySelectorAll("canvas,img,image,svg,[style*='background' i]")) - ]; - const scored = candidates - .map((candidate) => { - const rect = candidate.getBoundingClientRect(); - const style = window.getComputedStyle(candidate); - if (style.visibility === "hidden" || - style.display === "none" || - rect.width <= 0 || - rect.height <= 0) { - return null; - } - - const semantic = [ - candidate.getAttribute?.("data-testid"), - candidate.getAttribute?.("aria-label"), - candidate.getAttribute?.("title"), - candidate.className?.baseVal || candidate.className, - candidate.tagName - ].filter(Boolean).join(" ").toLowerCase(); - let score = rect.width * rect.height; - if (candidate.matches("canvas,img,image,svg")) score += 100000; - if (/sticker|emoji|lottie|emoticon|\u0441\u0442\u0438\u043a\u0435\u0440|\u044d\u043c\u043e\u0434\u0437\u0438/.test(semantic)) score += 50000; - return { candidate, score }; - }) - .filter(Boolean) - .sort((a, b) => b.score - a.score); - - return scored[0]?.candidate || element; - }).catch(() => null); - - const element = visual?.asElement?.(); - if (element) { - return element; - } - - await visual?.dispose?.().catch(() => {}); - return handle; - }; - const resolveVisualHandles = async (handles) => { - const resolved = []; - for (const handle of handles) { - const visual = await visualHandleFor(handle); - resolved.push(visual); - if (visual !== handle) { - resolved.push(handle); - } - } - return resolved; - }; - const captureCanvasPng = async (handle) => { - const result = await handle.evaluate(async (node) => { - const element = node instanceof Element ? node : node?.parentElement; - const canvas = element?.matches?.("canvas") - ? element - : element?.querySelector?.("canvas"); - if (!(canvas instanceof HTMLCanvasElement)) { - return { hasCanvas: false, blocked: false, dataUrl: null }; - } - - const sleep = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); - const hasPaintedPixels = () => { - const width = canvas.width; - const height = canvas.height; - if (width <= 0 || height <= 0) return false; - - let data; - try { - const context = canvas.getContext("2d", { willReadFrequently: true }); - if (!context) return true; - data = context.getImageData(0, 0, width, height).data; - } catch (_error) { - return null; - } - - const totalPixels = width * height; - const step = Math.max(1, Math.floor(totalPixels / 12000)); - let painted = 0; - for (let pixel = 0; pixel < totalPixels; pixel += step) { - if (data[pixel * 4 + 3] > 8) { - painted++; - } - } - - return painted >= Math.max(6, Math.ceil(totalPixels / step * 0.01)); - }; - - for (let attempt = 0; attempt < 10; attempt++) { - const painted = hasPaintedPixels(); - if (painted === true) { - try { - return { hasCanvas: true, blocked: false, dataUrl: canvas.toDataURL("image/png") }; - } catch (_error) { - return { hasCanvas: true, blocked: true, dataUrl: null }; - } - } - - if (painted === null) { - try { - return { hasCanvas: true, blocked: true, dataUrl: canvas.toDataURL("image/png") }; - } catch (_error) { - return { hasCanvas: true, blocked: true, dataUrl: null }; - } - } - - await sleep(200); - } - - return { hasCanvas: true, blocked: false, dataUrl: null }; - }).catch(() => ({ hasCanvas: false, blocked: false, dataUrl: null })); - - const dataUrl = String(result?.dataUrl || ""); - const prefix = "data:image/png;base64,"; - return { - hasCanvas: Boolean(result?.hasCanvas), - blocked: Boolean(result?.blocked), - buffer: dataUrl.startsWith(prefix) ? Buffer.from(dataUrl.slice(prefix.length), "base64") : null - }; - }; - const inspectStickerPng = async (buffer) => { - const base64 = buffer.toString("base64"); - return await p.evaluate(async (dataUrl) => { - const image = new Image(); - image.src = dataUrl; - await image.decode(); - - const canvas = document.createElement("canvas"); - canvas.width = image.naturalWidth; - canvas.height = image.naturalHeight; - const context = canvas.getContext("2d", { willReadFrequently: true }); - if (!context || canvas.width <= 0 || canvas.height <= 0) { - return null; - } - - context.drawImage(image, 0, 0); - const data = context.getImageData(0, 0, canvas.width, canvas.height).data; - const totalPixels = canvas.width * canvas.height; - const step = Math.max(1, Math.floor(totalPixels / 20000)); - let sampled = 0; - let opaque = 0; - let dark = 0; - let white = 0; - let cyan = 0; - let sumR = 0; - let sumG = 0; - let sumB = 0; - - for (let pixel = 0; pixel < totalPixels; pixel += step) { - const offset = pixel * 4; - const r = data[offset]; - const g = data[offset + 1]; - const b = data[offset + 2]; - const a = data[offset + 3]; - sampled++; - if (a <= 8) { - continue; - } - - opaque++; - sumR += r; - sumG += g; - sumB += b; - if (Math.max(r, g, b) < 95) dark++; - if (Math.min(r, g, b) > 230) white++; - if (b > r + 25 && g > r + 20 && b > 150 && g > 130) cyan++; - } - - return { - width: canvas.width, - height: canvas.height, - sampled, - opaqueRatio: sampled ? opaque / sampled : 0, - darkRatio: opaque ? dark / opaque : 0, - whiteRatio: opaque ? white / opaque : 0, - cyanRatio: opaque ? cyan / opaque : 0, - avgR: opaque ? sumR / opaque : 0, - avgG: opaque ? sumG / opaque : 0, - avgB: opaque ? sumB / opaque : 0 - }; - }, `data:image/png;base64,${base64}`).catch(() => null); - }; - const isMaxWallpaperCapture = (metrics) => { - if (!metrics) { - return false; - } - - return metrics.opaqueRatio > 0.98 && - metrics.cyanRatio > 0.72 && - metrics.darkRatio < 0.03 && - metrics.whiteRatio < 0.08 && - metrics.avgB > metrics.avgR + 45 && - metrics.avgG > metrics.avgR + 30; - }; - const stripMaxWallpaperFromStickerPng = async (buffer) => { - const base64 = buffer.toString("base64"); - const strippedDataUrl = await p.evaluate(async (dataUrl) => { - const image = new Image(); - image.src = dataUrl; - await image.decode(); - - const canvas = document.createElement("canvas"); - canvas.width = image.naturalWidth; - canvas.height = image.naturalHeight; - const context = canvas.getContext("2d", { willReadFrequently: true }); - if (!context || canvas.width <= 0 || canvas.height <= 0) { - return null; - } - - context.drawImage(image, 0, 0); - const imageData = context.getImageData(0, 0, canvas.width, canvas.height); - const data = imageData.data; - let kept = 0; - - for (let offset = 0; offset < data.length; offset += 4) { - const r = data[offset]; - const g = data[offset + 1]; - const b = data[offset + 2]; - const a = data[offset + 3]; - if (a <= 8) { - continue; - } - - const looksLikeMaxWallpaper = b > r + 25 && - g > r + 20 && - b > 150 && - g > 130; - if (looksLikeMaxWallpaper) { - data[offset + 3] = 0; - continue; - } - - kept++; - } - - if (kept < Math.max(80, Math.floor(canvas.width * canvas.height * 0.01))) { - return null; - } - - context.putImageData(imageData, 0, 0); - return canvas.toDataURL("image/png"); - }, `data:image/png;base64,${base64}`).catch(() => null); - - const prefix = "data:image/png;base64,"; - return typeof strippedDataUrl === "string" && strippedDataUrl.startsWith(prefix) - ? Buffer.from(strippedDataUrl.slice(prefix.length), "base64") - : null; - }; - const hasMeaningfulStickerContent = (metrics) => { - if (!metrics) { - return false; - } - - return metrics.opaqueRatio > 0.01 && - (metrics.darkRatio > 0.01 || - metrics.whiteRatio > 0.03 || - metrics.cyanRatio < 0.5); - }; - const stickerCaptureResult = async (buffer) => { - const metrics = await inspectStickerPng(buffer); - if (isMaxWallpaperCapture(metrics)) { - const stripped = await stripMaxWallpaperFromStickerPng(buffer); - if (stripped) { - const strippedMetrics = await inspectStickerPng(stripped); - if (!isMaxWallpaperCapture(strippedMetrics) && hasMeaningfulStickerContent(strippedMetrics)) { - return { contentType: "image/png", buffer: stripped }; - } - } - - throw new Error(`${kindLabel} capture matched MAX chat wallpaper instead of sticker.`); - } - - return { contentType: "image/png", buffer }; - }; - const screenshotCandidate = async (handles, requestedIndex) => { - const candidates = []; - for (const handle of handles) { - const meta = await handle.evaluate((node, options) => { - const requestedTimeText = String(options?.timeText || ""); - const acceptSmall = Boolean(options?.acceptSmall); - const element = node instanceof Element ? node : node.parentElement; - if (!element) { - return null; - } - - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - if (style.visibility === "hidden" || - style.display === "none" || - rect.width <= 0 || - rect.height <= 0 || - rect.bottom < 0 || - rect.right < 0 || - rect.top > window.innerHeight || - rect.left > window.innerWidth) { - return null; - } - - const wrapper = element.closest("[class*='messageWrapper' i], [role='listitem'], [class*='item' i], [class*='bubbleContent' i], [class*='bordersWrapper' i]") || element; - const cleanText = (value) => String(value || "") - .replace(/\u00a0/g, " ") - .replace(/\s+/g, " ") - .trim(); - const wrapperText = cleanText(wrapper.innerText || wrapper.textContent); - const metaText = Array.from(wrapper.querySelectorAll("[class*='meta' i], time")) - .map((item) => cleanText( - item.getAttribute?.("datetime") || - item.getAttribute?.("aria-label") || - item.getAttribute?.("title") || - item.innerText || - item.textContent)) - .filter(Boolean) - .join(" "); - const timeMatches = requestedTimeText && - (wrapperText.includes(requestedTimeText) || metaText.includes(requestedTimeText)); - const semanticRoot = element.closest("[data-testid^='sticker-'], [data-testid*='emoji' i], [class*='sticker' i], [class*='emoji' i], [class*='lottie' i]") || element; - const semantic = [ - element.getAttribute("data-testid"), - element.getAttribute("aria-label"), - element.getAttribute("title"), - element.className, - semanticRoot.getAttribute?.("data-testid"), - semanticRoot.getAttribute?.("aria-label"), - semanticRoot.getAttribute?.("title"), - semanticRoot.className, - wrapper.className, - wrapperText - ].filter(Boolean).join(" ").toLowerCase(); - const looksLikeSticker = /sticker|\u0441\u0442\u0438\u043a\u0435\u0440|emoji|\u044d\u043c\u043e\u0434\u0437\u0438|lottie|emoticon/.test(semantic) || - Boolean(element.querySelector("canvas,img,svg")) || - element.matches("canvas,img,svg"); - const area = rect.width * rect.height; - let score = 0; - if (looksLikeSticker) score += 4; - if (timeMatches) score += 40; - if (acceptSmall && element.matches("canvas,img,svg")) score += 8; - if (rect.width >= (acceptSmall ? 20 : 72) && rect.height >= (acceptSmall ? 20 : 72)) score += 16; - if (rect.width >= 120 && rect.height >= 120) score += 8; - score += Math.min(12, area / 2500); - if (rect.width < (acceptSmall ? 12 : 40) || rect.height < (acceptSmall ? 12 : 40)) score -= 30; - if (area < (acceptSmall ? 256 : 4096)) score -= 20; - return { - score, - x: rect.x, - width: rect.width, - height: rect.height, - y: rect.y, - area, - timeMatches: Boolean(timeMatches) - }; - }, { timeText, acceptSmall: Boolean(request?.acceptSmall) }).catch(() => null); - - if (!meta || meta.score <= 0) { - await handle.dispose().catch(() => {}); - continue; - } - - candidates.push({ handle, meta }); - } - - const strict = timeText - ? candidates.filter((candidate) => candidate.meta.timeMatches) - : candidates; - if (timeText && request?.requireTimeMatch && strict.length === 0) { - for (const item of candidates) { - await item.handle.dispose().catch(() => {}); - } - return null; - } - const sorted = (strict.length ? strict : candidates) - .sort((a, b) => b.meta.score - a.meta.score || b.meta.area - a.meta.area || b.meta.y - a.meta.y); - if (sorted.length === 0) { - return null; - } - const selectedIndex = Math.max(0, Math.min(requestedIndex, sorted.length - 1)); - const orderedTargets = [ - ...sorted.slice(selectedIndex), - ...sorted.slice(0, selectedIndex) - ]; - - const screenshotClip = async (box) => { - if (!Number.isFinite(box?.x) || - !Number.isFinite(box?.y) || - !Number.isFinite(box?.width) || - !Number.isFinite(box?.height)) { - return null; - } - - const viewport = p.viewportSize() || { width: 1280, height: 720 }; - const x = Math.max(0, Math.floor(box.x)); - const y = Math.max(0, Math.floor(box.y)); - const width = Math.min(Math.ceil(box.width), Math.max(1, viewport.width - x)); - const height = Math.min(Math.ceil(box.height), Math.max(1, viewport.height - y)); - if (width <= 0 || height <= 0) { - return null; - } - const maxClipSize = Number(request?.maxClipSize || 0); - if (maxClipSize > 0 && (width > maxClipSize || height > maxClipSize)) { - return null; - } - - return await p.screenshot({ - type: "png", - clip: { x, y, width, height }, - timeout: 10000 - }); - }; - - const captureErrors = []; - try { - for (const target of orderedTargets) { - const metaBox = { - x: target.meta.x, - y: target.meta.y, - width: target.meta.width, - height: target.meta.height - }; - const currentBoxOrMeta = async () => { - const box = await target.handle.boundingBox().catch(() => null); - return box && box.width > 0 && box.height > 0 ? box : metaBox; - }; - - try { - await target.handle.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {}); - const canvasCapture = await captureCanvasPng(target.handle); - if (canvasCapture.buffer) { - return await stickerCaptureResult(canvasCapture.buffer); - } - - let buffer; - if (request?.acceptSmall) { - buffer = await screenshotClip(await currentBoxOrMeta()); - if (!buffer) { - throw new Error(`${kindLabel} clip was not visible.`); - } - } else { - try { - buffer = await target.handle.screenshot({ type: "png", timeout: 5000 }); - } catch (error) { - buffer = await screenshotClip(await currentBoxOrMeta()); - if (!buffer) { - throw error; - } - } - } - if (!buffer) { - buffer = await target.handle.screenshot({ type: "png", timeout: 5000 }); - } - return await stickerCaptureResult(buffer); - } catch (error) { - captureErrors.push(error?.message || String(error)); - } - } - - if (captureErrors.length > 0) { - throw new Error(`${kindLabel} was not captured: ${captureErrors.slice(0, 5).join("; ")}`); - } - - return null; - } finally { - for (const item of candidates) { - await item.handle.dispose().catch(() => {}); - } - } - }; - - const index = Number.isFinite(Number(request?.index)) ? Number(request.index) : 0; - let testIdCaptureError = null; - if (testId) { - const handles = await p.$$(selectorForTestId(testId)); - try { - const result = await screenshotCandidate(await resolveVisualHandles(handles), 0); - if (result) { - return result; - } - } catch (error) { - testIdCaptureError = error; - } - } - - const handles = await p.$$(fallbackSelector); - const result = await screenshotCandidate(await resolveVisualHandles(handles), index); - if (!result) { - if (testIdCaptureError) { - throw testIdCaptureError; - } - - throw new Error(`${kindLabel} was not found.`); - } - - return result; -} - -function contentTypeFromFileName(fileName) { - const ext = String(fileName || "").toLowerCase().split(".").pop(); - switch (ext) { - case "txt": return "text/plain"; - case "csv": return "text/csv"; - case "vcf": return "text/vcard"; - case "pdf": return "application/pdf"; - case "zip": return "application/zip"; - case "rar": return "application/vnd.rar"; - case "7z": return "application/x-7z-compressed"; - case "doc": return "application/msword"; - case "docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; - case "xls": return "application/vnd.ms-excel"; - case "xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - case "ppt": return "application/vnd.ms-powerpoint"; - case "pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; - default: return "application/octet-stream"; - } -} - -function attachNetworkCapture(p) { - if (p.__qmaxNetworkCaptureAttached) { - return; - } - - p.__qmaxNetworkCaptureAttached = true; - p.on("response", async (response) => { - try { - const request = response.request(); - const url = response.url(); - if (!url.includes("max.ru")) { - return; - } - - const headers = response.headers(); - networkLog.push({ - at: new Date().toISOString(), - method: request.method(), - url: redactUrl(url), - status: response.status(), - resourceType: request.resourceType(), - contentType: String(headers["content-type"] || "").split(";")[0] - }); - if (networkLog.length > 500) { - networkLog.splice(0, networkLog.length - 500); - } - } catch { - // Network inspection is best-effort and must never break the bridge. - } - }); -} - -function redactUrl(value) { - try { - const url = new URL(value); - for (const key of Array.from(url.searchParams.keys())) { - if (/token|auth|code|session|secret|key|hash/i.test(key)) { - url.searchParams.set(key, "[redacted]"); - } - } - return url.toString(); - } catch { - return String(value || ""); - } -} - -async function inspectDom(p) { - return await p.evaluate(() => { - const cleanText = (value, limit = 500) => String(value || "").replace(/\s+/g, " ").trim().slice(0, limit); - const isVisible = (element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - - const cssImageUrl = (element) => { - const style = window.getComputedStyle(element); - const value = [ - style.backgroundImage, - style.maskImage, - style.webkitMaskImage - ].find((item) => item && item !== "none") || ""; - const match = value.match(/url\(["']?(.+?)["']?\)/i); - if (!match) return null; - try { - return new URL(match[1], location.href).href; - } catch { - return match[1]; - } - }; - - const elements = Array.from(document.querySelectorAll("a,button,input,textarea,img,picture,video,audio,canvas,svg,[role],[data-testid],[class]")) - .filter(isVisible) - .map((element) => { - const rect = element.getBoundingClientRect(); - const tag = element.tagName.toLowerCase(); - const mediaSrc = element.currentSrc || - element.src || - element.poster || - element.href?.baseVal || - element.getAttribute("src") || - element.getAttribute("href") || - element.getAttribute("xlink:href") || - element.getAttribute("data-url") || - element.getAttribute("data-src") || - cssImageUrl(element); - return { - tag, - role: element.getAttribute("role"), - id: element.id || null, - className: cleanText(element.className, 180), - testId: element.getAttribute("data-testid"), - ariaLabel: element.getAttribute("aria-label"), - href: element.getAttribute("href"), - mediaSrc, - text: cleanText(element.innerText || element.value || element.getAttribute("aria-label")), - rect: { - x: Math.round(rect.x), - y: Math.round(rect.y), - width: Math.round(rect.width), - height: Math.round(rect.height) - } - }; - }) - .filter((element) => { - const haystack = `${element.tag} ${element.className} ${element.mediaSrc || ""}`.toLowerCase(); - return element.text || - element.ariaLabel || - element.testId || - element.role || - element.mediaSrc || - /message|bubble|sticker|emoji|attach|media|image|picture|canvas|svg/.test(haystack); - }) - .slice(0, 400); - - return { - url: location.href, - title: document.title, - bodyText: cleanText(document.body?.innerText, 2000), - capturedAt: new Date().toISOString(), - viewport: { width: window.innerWidth, height: window.innerHeight }, - elements - }; - }); -} - -async function inspectStorage(p) { - return await p.evaluate(async () => { - const storageKeys = (storage) => { - const keys = []; - for (let index = 0; index < storage.length; index++) { - const key = storage.key(index); - const value = key ? storage.getItem(key) : null; - keys.push({ - key, - valueLength: value ? value.length : 0 - }); - } - return keys; - }; - - const indexedDbDatabases = []; - if (indexedDB.databases) { - for (const dbInfo of await indexedDB.databases()) { - const entry = { - name: dbInfo.name, - version: dbInfo.version, - objectStores: [] - }; - if (dbInfo.name) { - try { - const opened = await new Promise((resolve, reject) => { - const request = indexedDB.open(dbInfo.name); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - }); - for (const storeName of Array.from(opened.objectStoreNames)) { - const transaction = opened.transaction(storeName, "readonly"); - const store = transaction.objectStore(storeName); - const count = await new Promise((resolve) => { - const request = store.count(); - request.onerror = () => resolve(null); - request.onsuccess = () => resolve(request.result); - }); - entry.objectStores.push({ name: storeName, count }); - } - opened.close(); - } catch { - entry.error = "open failed"; - } - } - indexedDbDatabases.push(entry); - } - } - - return { - url: location.href, - capturedAt: new Date().toISOString(), - localStorage: storageKeys(localStorage), - sessionStorage: storageKeys(sessionStorage), - indexedDbDatabases - }; - }); -} - -async function inspectIndexedDbSamples(p, options) { - return await p.evaluate(async (opts) => { - const sensitiveKeyPattern = /(token|auth|session|secret|cookie|password|phone|email|jwt|bearer|refresh|access|credential|csrf|hash|salt|signature)/i; - - const truncate = (value, limit) => { - const text = String(value ?? ""); - return text.length > limit - ? `${text.slice(0, limit)}...` - : text; - }; - - const summarize = (value, keyHint = "", depth = 0) => { - const type = Array.isArray(value) ? "array" : typeof value; - if (value === null || value === undefined) { - return { type: String(value) }; - } - - if (sensitiveKeyPattern.test(keyHint)) { - return { type, redacted: true }; - } - - if (type === "string") { - return { - type, - length: value.length, - preview: truncate(value, opts.stringLimit) - }; - } - - if (type === "number" || type === "boolean") { - return { type, value }; - } - - if (value instanceof Date) { - return { type: "date", value: value.toISOString() }; - } - - if (value instanceof ArrayBuffer) { - return { type: "arrayBuffer", byteLength: value.byteLength }; - } - - if (ArrayBuffer.isView(value)) { - return { type: value.constructor.name, byteLength: value.byteLength }; - } - - if (Array.isArray(value)) { - return { - type, - length: value.length, - items: depth >= 2 - ? [] - : value.slice(0, 5).map((item, index) => summarize(item, `${keyHint}[${index}]`, depth + 1)) - }; - } - - if (type === "object") { - const entries = Object.entries(value).slice(0, 40); - return { - type, - keys: Object.keys(value).slice(0, 80), - fields: depth >= 3 - ? {} - : Object.fromEntries(entries.map(([key, fieldValue]) => [key, summarize(fieldValue, key, depth + 1)])) - }; - } - - return { type }; - }; - - const requestAsPromise = (request) => new Promise((resolve, reject) => { - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - }); - - const openDatabase = (name) => new Promise((resolve, reject) => { - const request = indexedDB.open(name); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - }); - - const databases = indexedDB.databases - ? (await indexedDB.databases()).filter((db) => db.name).slice(0, opts.dbLimit) - : []; - - const result = []; - for (const dbInfo of databases) { - const database = { - name: dbInfo.name, - version: dbInfo.version, - stores: [] - }; - - try { - const opened = await openDatabase(dbInfo.name); - const storeNames = Array.from(opened.objectStoreNames).slice(0, opts.storeLimit); - for (const storeName of storeNames) { - const transaction = opened.transaction(storeName, "readonly"); - const store = transaction.objectStore(storeName); - const count = await requestAsPromise(store.count()).catch(() => null); - const records = await requestAsPromise(store.getAll(undefined, opts.sampleLimit)).catch(() => []); - database.stores.push({ - name: storeName, - keyPath: store.keyPath, - autoIncrement: store.autoIncrement, - count, - samples: records.map((record) => summarize(record)) - }); - } - opened.close(); - } catch (error) { - database.error = String(error?.message || error); - } - - result.push(database); - } - - return { - url: location.href, - capturedAt: new Date().toISOString(), - limits: opts, - databases: result - }; - }, options); -} - -function cleanComparableText(value, limit = 500) { - return String(value || "") - .replace(/\u00a0/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, limit); -} - -function isGenericMediaText(value) { - return /^(\u0444\u043e\u0442\u043e|photo|image|gif|\u0432\u0438\u0434\u0435\u043e|video|\u0430\u0443\u0434\u0438\u043e|audio|\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435|voice|\u0441\u0442\u0438\u043a\u0435\u0440|sticker|\u044d\u043c\u043e\u0434\u0437\u0438|emoji|\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b[\u0435\u0451]\u043d(?:\u043d\u044b\u0435|\u043d\u043e\u0435)?\s+\u0444\u043e\u0442\u043e)$/i - .test(cleanComparableText(value, 120)); -} - -async function extractUpdates(p) { - const raw = await p.evaluate(async () => { - const cleanText = (value, limit = 500) => String(value || "") - .replace(/\u00a0/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, limit); - const linesFrom = (value) => String(value || "") - .split(/\n+/) - .map((line) => cleanText(line, 160)) - .filter(Boolean); - const isVisible = (element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 20 && - rect.height > 12 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const isChatRowVisibleEnough = (element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 20 && - rect.height > 12 && - rect.bottom >= -140 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const badText = /(\u0432\u043e\u0439\u0442\u0438|qr|\u043d\u0435 \u0440\u043e\u0431\u043e\u0442|\u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430|\u043f\u043e\u043b\u0438\u0442\u0438\u043a|\u0441\u043e\u0433\u043b\u0430\u0448\u0435\u043d|\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u043e\u0432\u044b\u0439 \u043a\u043e\u0434|\u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434|\u043a\u043e\u0434 max|\u0441\u043c\u0441|sms)/i; - const isGenericMediaText = (value) => /^(\u0444\u043e\u0442\u043e|photo|image|gif|\u0432\u0438\u0434\u0435\u043e|video|\u0430\u0443\u0434\u0438\u043e|audio|\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435|voice|\u0441\u0442\u0438\u043a\u0435\u0440|sticker|\u044d\u043c\u043e\u0434\u0437\u0438|emoji|\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b[\u0435\u0451]\u043d(?:\u043d\u044b\u0435|\u043d\u043e\u0435)?\s+\u0444\u043e\u0442\u043e)$/i.test(cleanText(value, 120)); - const absoluteUrl = (value) => { - const raw = String(value || "").trim(); - if (!raw || raw.startsWith("data:")) return null; - try { - return new URL(raw, location.href).href; - } catch { - return null; - } - }; - const cssBackgroundUrl = (element) => { - const image = window.getComputedStyle(element).backgroundImage || ""; - const match = image.match(/url\(["']?(.+?)["']?\)/i); - return match ? absoluteUrl(match[1]) : null; - }; - const extractAvatarUrl = (element) => { - const rowRect = element.getBoundingClientRect(); - const candidates = Array.from(element.querySelectorAll("img, image, [style*='background' i], [class*='avatar' i], [class*='photo' i]")); - for (const node of candidates) { - if (!(node instanceof Element)) continue; - const rect = node.getBoundingClientRect(); - if (rect.width < 24 || rect.height < 24 || rect.width > 96 || rect.height > 96) continue; - if (rect.left > rowRect.left + rowRect.width * 0.45) continue; - const source = node.currentSrc || - node.src || - node.href?.baseVal || - node.getAttribute("src") || - node.getAttribute("href") || - cssBackgroundUrl(node); - const url = absoluteUrl(source); - if (url && !/pattern|sprite|emoji|sticker|logo/i.test(url)) { - return url; - } - } - return null; - }; - const extractChatUrl = (element) => { - const candidates = [ - element.getAttribute("href"), - element.closest("a")?.getAttribute("href"), - element.getAttribute("data-url"), - element.getAttribute("data-href"), - element.getAttribute("data-link"), - element.getAttribute("data-path") - ]; - for (const attr of Array.from(element.attributes || [])) { - if (/url|href|link|path/i.test(attr.name)) { - candidates.push(attr.value); - } - } - - for (const candidate of candidates) { - const url = absoluteUrl(candidate); - if (url) { - return url; - } - } - - return null; - }; - const extractTitle = (element, lines) => { - const titleNode = element.querySelector("h3, [class*='title' i] [class*='name' i], [class*='name' i]"); - const title = cleanText(titleNode?.innerText || titleNode?.textContent, 120); - return title || lines.find((line) => line && !/^\d{1,3}$/.test(line)) || ""; - }; - const extractPreview = (element, title, lines) => { - const timeText = cleanText(element.querySelector("[class*='time' i], [class*='meta' i]")?.innerText); - const ignored = new Set([ - title, - timeText, - cleanText(element.querySelector("[class*='badge' i], [class*='indicator' i], [class*='counter' i]")?.innerText) - ].filter(Boolean)); - const candidates = Array.from(element.querySelectorAll("span[class*='text' i], div[class*='text' i], p, [class*='subtitle' i]")) - .map((node) => cleanText(node.innerText || node.textContent, 240)) - .filter((text) => text && !ignored.has(text)) - .filter((text) => !/^\d{1,3}$/.test(text)); - return candidates.find((text) => text !== title) || - lines.find((line) => line !== title && !ignored.has(line) && !/^\d{1,3}$/.test(line)) || - null; - }; - const extractTimeText = (element, lines) => { - const direct = cleanText(element.querySelector("[class*='time' i], [class*='meta' i]")?.innerText, 80); - if (direct) return direct; - return lines.find((line) => - /^\d{1,2}:\d{2}$/.test(line) || - /^(\u0432\u0447\u0435\u0440\u0430|\u0441\u0435\u0433\u043e\u0434\u043d\u044f)$/i.test(line) || - /^\d{1,2}\s+[^\s.]+\.?$/i.test(line) || - /^(mon|tue|wed|thu|fri|sat|sun|\u043f\u043d|\u0432\u0442|\u0441\u0440|\u0447\u0442|\u043f\u0442|\u0441\u0431|\u0432\u0441)$/i.test(line) - ) || null; - }; - const classifyChatKind = (element, title, lines, text, href) => { - const semantic = [ - title, - text, - lines.join("\n"), - href, - element.getAttribute("aria-label"), - element.getAttribute("title"), - element.getAttribute("data-testid"), - element.className, - Array.from(element.querySelectorAll("[aria-label], [title], [data-testid], svg, use")) - .map((node) => [ - node.getAttribute("aria-label"), - node.getAttribute("title"), - node.getAttribute("data-testid"), - node.getAttribute("href"), - node.getAttribute("xlink:href"), - node.className - ].join(" ")) - .join(" ") - ].join(" ").toLowerCase(); - return /(\u043a\u0430\u043d\u0430\u043b|channel|broadcast|\u043f\u043e\u0434\u043f\u0438\u0441\u0447|\u043f\u043e\u0434\u043f\u0438\u0441\u0430|subscriber)/i.test(semantic) - ? "Channel" - : "MaxDialog"; - }; - const chatSelector = [ - "aside button.cell", - "aside button[class*='cell' i]", - "aside [role='presentation'] button", - "aside [class*='wrapper' i] button", - "[data-testid*='chat' i]", - "[data-testid*='dialog' i]", - "[data-testid*='conversation' i]", - "a[href*='chat' i]", - "a[href*='dialog' i]" - ].join(","); - const seen = new Set(); - const chats = []; - const collectVisibleChats = () => { - for (const element of Array.from(document.querySelectorAll(chatSelector))) { - if (!isChatRowVisibleEnough(element)) continue; - const rect = element.getBoundingClientRect(); - const text = cleanText(element.innerText || element.textContent, 400); - if (!text || text.length < 2 || text.length > 380 || badText.test(text)) continue; - const lines = linesFrom(element.innerText || element.textContent); - if (lines.length === 0) continue; - const title = extractTitle(element, lines); - if (!title || title.length < 2 || badText.test(title)) continue; - const semantic = [ - element.getAttribute("role"), - element.getAttribute("data-testid"), - element.className, - element.getAttribute("href") - ].join(" "); - const looksLikeChat = element.matches("button.cell, button[class*='cell' i]") || - lines.length >= 2 || - /chat|dialog|conversation|listitem|cell/i.test(semantic) || - (rect.left > 60 && rect.left < window.innerWidth * 0.55); - if (!looksLikeChat) continue; - const avatarUrl = extractAvatarUrl(element); - const href = extractChatUrl(element); - const kind = classifyChatKind(element, title, lines, text, href); - const key = `${title}|${avatarUrl || href || text}`; - if (seen.has(key)) continue; - seen.add(key); - chats.push({ - title, - text, - preview: extractPreview(element, title, lines), - timeText: extractTimeText(element, lines), - avatarUrl, - href, - kind, - rect: { - x: Math.round(rect.x), - y: Math.round(rect.y), - width: Math.round(rect.width), - height: Math.round(rect.height) - } - }); - if (chats.length >= 600) break; - } - }; - - const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - const scrollRoot = Array.from(document.querySelectorAll("aside, aside *")) - .filter((element) => element instanceof HTMLElement) - .filter((element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.display !== "none" && - style.visibility !== "hidden" && - rect.width > 180 && - rect.height > 160 && - element.scrollHeight > element.clientHeight + 80; - }) - .sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0] || null; - - if (scrollRoot) { - const maxTop = () => Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight); - scrollRoot.scrollTop = 0; - scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); - await delay(120); - - let previousTop = -1; - for (let page = 0; page < 180 && chats.length < 600; page++) { - collectVisibleChats(); - const bottom = maxTop(); - if (scrollRoot.scrollTop >= bottom - 4) break; - const nextTop = Math.min(bottom, scrollRoot.scrollTop + Math.max(220, Math.floor(scrollRoot.clientHeight * 0.8))); - if (nextTop === previousTop || nextTop === scrollRoot.scrollTop) break; - previousTop = scrollRoot.scrollTop; - scrollRoot.scrollTop = nextTop; - scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); - await delay(120); - } - - scrollRoot.scrollTop = 0; - scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); - } else { - collectVisibleChats(); - } - - return { - url: location.href, - title: document.title, - chats - }; - }); - - const now = new Date().toISOString(); - const seenExternalIds = new Set(); - return raw.chats.flatMap((chat, index) => { - const externalId = makeDomChatExternalId(chat.title, chat.text, chat.href, index, chat.avatarUrl); - if (seenExternalIds.has(externalId)) { - return []; - } - - seenExternalIds.add(externalId); - const outgoing = /^\s*(\u0432\u044b|you)\s*:/i.test(chat.preview || ""); - const messageText = (chat.preview || "").replace(/^(\u0432\u044b|you)\s*:\s*/i, "").trim(); - return [{ - externalId, - title: chat.title, - avatarUrl: chat.avatarUrl || null, - chatUrl: chat.href || null, - kind: chat.kind || "MaxDialog", - updatedAt: now, - lastMessagePreview: messageText || null, - lastMessageIsOutgoing: outgoing, - lastMessageAt: messageText ? now : null, - lastMessageTimeText: chat.timeText || null, - messages: [] - }]; - }); -} - -async function extractChannelUpdates(p, allUpdates = []) { - const selected = await selectSidebarFilter(p, ["\u041a\u0430\u043d\u0430\u043b\u044b", "Channels"]); - if (!selected) { - return []; - } - - await clearSidebarSearch(p); - await resetSidebarListToTop(p); - const updates = await extractUpdates(p); - if (looksLikeUnfilteredChannelFolder(allUpdates, updates)) { - return []; - } - - return updates.map((update) => ({ ...update, kind: "Channel" })); -} - -function looksLikeUnfilteredChannelFolder(allUpdates, channelUpdates) { - if (!Array.isArray(allUpdates) || - !Array.isArray(channelUpdates) || - allUpdates.length < 10 || - channelUpdates.length < 10) { - return false; - } - - const allIds = new Set(allUpdates.map((update) => update.externalId).filter(Boolean)); - if (allIds.size === 0) { - return false; - } - - const overlap = channelUpdates.filter((update) => allIds.has(update.externalId)).length; - const overlapRatio = overlap / Math.max(1, Math.min(allIds.size, channelUpdates.length)); - const sameLeading = allUpdates - .slice(0, 12) - .filter((update, index) => update.externalId && update.externalId === channelUpdates[index]?.externalId) - .length; - return overlapRatio > 0.85 && sameLeading >= 8; -} - -function mergeChatUpdates(updates) { - const byExternalId = new Map(); - for (const update of updates) { - if (!update?.externalId) { - continue; - } - - const existing = byExternalId.get(update.externalId); - if (!existing) { - byExternalId.set(update.externalId, update); - continue; - } - - byExternalId.set(update.externalId, { - ...existing, - ...update, - messages: (update.messages || []).length > 0 ? update.messages : (existing.messages || []), - kind: update.kind === "Channel" || existing.kind === "Channel" ? "Channel" : (update.kind || existing.kind || "MaxDialog"), - avatarUrl: update.avatarUrl || existing.avatarUrl || null, - chatUrl: update.chatUrl || existing.chatUrl || null, - lastMessagePreview: update.lastMessagePreview || existing.lastMessagePreview || null, - lastMessageAt: update.lastMessageAt || existing.lastMessageAt || null, - lastMessageTimeText: update.lastMessageTimeText || existing.lastMessageTimeText || null - }); - } - - return Array.from(byExternalId.values()); -} - -async function extractOpenChatHistory(p, requestedExternalChatId) { - const requestedTitle = decodeDomChatTitle(requestedExternalChatId); - const raw = await p.evaluate((fallbackTitle) => { - const cleanText = (value, limit = 4000) => String(value || "") - .replace(/\u00a0/g, " ") - .replace(/\s+\n/g, "\n") - .replace(/\n\s+/g, "\n") - .replace(/[ \t]+/g, " ") - .trim() - .slice(0, limit); - const isGenericMediaText = (value) => /^(\u0444\u043e\u0442\u043e|photo|image|gif|\u0432\u0438\u0434\u0435\u043e|video|\u0430\u0443\u0434\u0438\u043e|audio|\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435|voice|\u0441\u0442\u0438\u043a\u0435\u0440|sticker|\u044d\u043c\u043e\u0434\u0437\u0438|emoji|\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b[\u0435\u0451]\u043d(?:\u043d\u044b\u0435|\u043d\u043e\u0435)?\s+\u0444\u043e\u0442\u043e)$/i.test(cleanText(value, 120)); - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const opened = document.querySelector("[class*='openedChat' i]") || - document.querySelector("main") || - document.body; - const openedRect = opened.getBoundingClientRect(); - const stripChatWindowPrefix = (value) => cleanText(value, 160) - .replace(/^\u041e\u043a\u043d\u043e\s+\u0447\u0430\u0442\u0430\s+\u0441\s+/i, "") - .trim(); - const selectedTitle = cleanText(document.querySelector("aside button[class*='selected' i] h3")?.innerText, 160); - const header = opened.querySelector("[class*='header' i]"); - const headerTitle = cleanText( - header?.querySelector("h1,h2,h3,[class*='title' i],[class*='name' i]")?.innerText || - header?.querySelector("button[aria-label]")?.innerText, - 160); - const title = stripChatWindowPrefix(selectedTitle) || - stripChatWindowPrefix(headerTitle) || - stripChatWindowPrefix(fallbackTitle) || - "\u0427\u0430\u0442 MAX"; - const classifyOpenChatKind = () => { - const semantic = [ - title, - cleanText(header?.innerText || header?.textContent, 1000), - header?.getAttribute("aria-label"), - header?.getAttribute("title"), - header?.getAttribute("data-testid"), - header?.className, - Array.from(opened.querySelectorAll("[aria-label], [title], [data-testid], svg, use")) - .slice(0, 120) - .map((node) => [ - node.getAttribute("aria-label"), - node.getAttribute("title"), - node.getAttribute("data-testid"), - node.getAttribute("href"), - node.getAttribute("xlink:href"), - node.className - ].join(" ")) - .join(" ") - ].join(" ").toLowerCase(); - return /(\u043a\u0430\u043d\u0430\u043b|channel|broadcast|\u043f\u043e\u0434\u043f\u0438\u0441\u0447|\u043f\u043e\u0434\u043f\u0438\u0441\u0430|subscriber)/i.test(semantic) - ? "Channel" - : null; - }; - - const parseDate = (dateLabel, timeText) => { - const now = new Date(); - const timeMatch = String(timeText || "").match(/(\d{1,2}):(\d{2})/); - const hour = timeMatch ? Number(timeMatch[1]) : 12; - const minute = timeMatch ? Number(timeMatch[2]) : 0; - const text = cleanText(dateLabel, 80).toLowerCase(); - const futureToleranceMs = 10 * 60 * 1000; - const setDayOffset = (days) => { - const result = new Date(now); - result.setHours(hour, minute, 0, 0); - result.setDate(result.getDate() - days); - if (days === 0 && result.getTime() - now.getTime() > futureToleranceMs) { - result.setDate(result.getDate() - 1); - } - return result.toISOString(); - }; - - if (!text || text.includes("\u0441\u0435\u0433\u043e\u0434\u043d\u044f")) { - return setDayOffset(0); - } - if (text.includes("\u043f\u043e\u0437\u0430\u0432\u0447\u0435\u0440\u0430")) { - return setDayOffset(2); - } - if (text.includes("\u0432\u0447\u0435\u0440\u0430")) { - return setDayOffset(1); - } - - const months = { - "\u044f\u043d\u0432\u0430\u0440\u044f": 0, - "\u0444\u0435\u0432\u0440\u0430\u043b\u044f": 1, - "\u043c\u0430\u0440\u0442\u0430": 2, - "\u0430\u043f\u0440\u0435\u043b\u044f": 3, - "\u043c\u0430\u044f": 4, - "\u0438\u044e\u043d\u044f": 5, - "\u0438\u044e\u043b\u044f": 6, - "\u0430\u0432\u0433\u0443\u0441\u0442\u0430": 7, - "\u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044f": 8, - "\u043e\u043a\u0442\u044f\u0431\u0440\u044f": 9, - "\u043d\u043e\u044f\u0431\u0440\u044f": 10, - "\u0434\u0435\u043a\u0430\u0431\u0440\u044f": 11 - }; - const match = text.match(/(\d{1,2})\s+([^\s.]+)\.?\s*(\d{4})?/i); - if (match) { - const day = Number(match[1]); - const month = months[match[2]]; - const year = match[3] ? Number(match[3]) : now.getFullYear(); - if (Number.isFinite(day) && month !== undefined && Number.isFinite(year)) { - const parsed = new Date(year, month, day, hour, minute, 0, 0); - if (!match[3] && parsed.getTime() - now.getTime() > futureToleranceMs) { - parsed.setFullYear(year - 1); - } - return parsed.toISOString(); - } - } - - return setDayOffset(0); - }; - const absoluteUrl = (value) => { - const raw = String(value || "").trim(); - if (!raw || raw.startsWith("data:")) return null; - try { - return new URL(raw, location.href).href; - } catch { - return null; - } - }; - const extensionFromUrl = (url) => { - try { - const pathname = new URL(url).pathname; - const match = pathname.match(/\.([a-z0-9]{2,8})$/i); - return match ? match[1].toLowerCase() : ""; - } catch { - return ""; - } - }; - const srcFromSrcset = (value) => { - const items = String(value || "") - .split(",") - .map((part) => part.trim().split(/\s+/)[0]) - .filter(Boolean); - return items.at(-1) || null; - }; - const isStickerUrl = (url) => /(^|\/)(stickers?|sticker-packs?)(\/|$)|st\.max\.ru\/stickers?/i.test(url || ""); - const isEmojiUrl = (url) => /(^|\/)emojis?(\/|$)|st\.max\.ru\/emojis?/i.test(url || ""); - const phoneFromText = (value) => { - const match = cleanText(value, 500).match(/(?:\+?\d[\d\s().-]{6,}\d)/); - if (!match) return null; - const phone = match[0] - .replace(/[^\d+]/g, "") - .replace(/(?!^)\+/g, ""); - return phone.length >= 7 ? phone : null; - }; - const isDownloadText = (value) => /^(download|\u0441\u043a\u0430\u0447\u0430\u0442\u044c|\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c|\u043e\u0442\u043a\u0440\u044b\u0442\u044c|open)$/i.test(cleanText(value, 80)); - const vcardEscape = (value) => cleanText(value, 200) - .replace(/\\/g, "\\\\") - .replace(/\n/g, "\\n") - .replace(/;/g, "\\;") - .replace(/,/g, "\\,"); - const safeContactFileName = (value) => { - const clean = cleanText(value || "\u041a\u043e\u043d\u0442\u0430\u043a\u0442", 80) - .replace(/[\\/:*?"<>|]+/g, " ") - .trim() || "\u041a\u043e\u043d\u0442\u0430\u043a\u0442"; - return `${clean.slice(0, 70)}.vcf`; - }; - const domDownloadUrlFromFile = (label, fileName) => `qmax-dom-download:${encodeURIComponent(JSON.stringify({ - label: cleanText(label, 300), - fileName: cleanText(fileName, 160) - }))}`; - const domAudioUrlFromVoice = (label, duration, timeText) => `qmax-dom-audio:${encodeURIComponent(JSON.stringify({ - label: cleanText(label, 300), - duration: cleanText(duration, 40), - timeText: cleanText(timeText, 40) - }))}`; - const domStickerUrlFromCanvas = (testId, timeText, index) => `qmax-dom-sticker:${encodeURIComponent(JSON.stringify({ - testId: cleanText(testId, 120), - timeText: cleanText(timeText, 40), - index - }))}`; - const domEmojiUrlFromCanvas = (testId, timeText, index) => `qmax-dom-emoji:${encodeURIComponent(JSON.stringify({ - testId: cleanText(testId, 120), - timeText: cleanText(timeText, 40), - index - }))}`; - const durationFromText = (value) => cleanText(value, 120).match(/\b\d{1,2}:\d{2}(?::\d{2})?\b/)?.[0] || ""; - const messageTimeTextFromWrapper = (wrapper) => { - const metaNodes = Array.from(wrapper.querySelectorAll("[class*='meta' i], time")) - .filter((node) => !node.closest("[class*='attach' i], [class*='audio' i], [class*='voice' i], [class*='duration' i]")); - for (const node of metaNodes) { - const text = cleanText( - node.getAttribute?.("datetime") || - node.getAttribute?.("aria-label") || - node.getAttribute?.("title") || - node.innerText || - node.textContent, - 80); - const match = text.match(/\b\d{1,2}:\d{2}\b/); - if (match) { - return match[0]; - } - } - - const ownText = cleanText(wrapper.innerText || wrapper.textContent, 500); - const matches = ownText.match(/\b\d{1,2}:\d{2}\b/g) || []; - return matches.at(-1) || ""; - }; - const safeVoiceFileName = (duration, index) => { - const suffix = cleanText(duration, 40).replace(/[^0-9]+/g, "-").replace(/^-+|-+$/g, ""); - return `max-voice-${suffix || index + 1}.ogg`; - }; - const safeStickerFileName = (testId, index) => { - const suffix = cleanText(testId, 80).replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, ""); - return `max-sticker-${suffix || index + 1}.png`; - }; - const safeEmojiFileName = (testId, index) => { - const suffix = cleanText(testId, 80).replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, ""); - return `max-emoji-${suffix || index + 1}.png`; - }; - const fileSizeBytesFromLabel = (label) => { - const match = cleanText(label, 300).match(/(\d+(?:[.,]\d+)?)\s*(b|kb|mb|gb|\u0431|\u043a\u0431|\u043c\u0431|\u0433\u0431)\b/i); - if (!match) return null; - const value = Number(match[1].replace(",", ".")); - if (!Number.isFinite(value) || value < 0) return null; - const unit = match[2].toLowerCase(); - const factor = unit === "gb" || unit === "\u0433\u0431" - ? 1024 * 1024 * 1024 - : unit === "mb" || unit === "\u043c\u0431" - ? 1024 * 1024 - : unit === "kb" || unit === "\u043a\u0431" - ? 1024 - : 1; - return Math.round(value * factor); - }; - const kindFromMedia = (tagName, url, label) => { - const ext = extensionFromUrl(url); - const text = `${tagName || ""} ${url || ""} ${label || ""}`.toLowerCase(); - if (ext === "vcf" || /vcard|text\/vcard/i.test(text)) return "Contact"; - if (isStickerUrl(url) || /sticker|\u0441\u0442\u0438\u043a\u0435\u0440/i.test(text)) return "Sticker"; - if (tagName === "video" || /\.(mp4|mov|webm|mkv|m4v|3gp)(\?|#|$)/i.test(url || "")) return "Video"; - if (tagName === "audio" || /\.(ogg|opus|m4a|aac|mp3|wav|flac|oga)(\?|#|$)/i.test(url || "") || /voice|audio|голос|аудио/i.test(text)) return "VoiceNote"; - if (ext === "gif" || /gif/i.test(text)) return "Gif"; - if (tagName === "img" || tagName === "image" || isEmojiUrl(url) || /\.(jpg|jpeg|png|webp|gif|bmp|heic|heif|avif)(\?|#|$)/i.test(url || "")) return "Image"; - return "File"; - }; - const contentTypeFromKind = (kind, url) => { - const ext = extensionFromUrl(url); - if (kind === "Gif") return "image/gif"; - if (kind === "Image") return ext ? `image/${ext === "jpg" ? "jpeg" : ext}` : "image/*"; - if (kind === "Video") return ext ? `video/${ext === "mov" ? "quicktime" : ext}` : "video/*"; - if (kind === "VoiceNote") return ext ? `audio/${ext === "m4a" ? "mp4" : ext}` : "audio/*"; - if (kind === "Sticker") return ext ? `image/${ext === "jpg" ? "jpeg" : ext}` : "image/webp"; - if (kind === "Contact") return "text/vcard; charset=utf-8"; - return "application/octet-stream"; - }; - const fileNameFromMedia = (label, kind, url, index) => { - const labelLines = String(label || "") - .split(/\n+| {2,}/) - .map((line) => cleanText(line, 160)) - .filter(Boolean); - const hasKnownExtension = (value) => /\.(jpg|jpeg|png|webp|gif|bmp|heic|heif|avif|mp4|mov|webm|mkv|m4v|3gp|ogg|opus|m4a|aac|mp3|wav|flac|oga|pdf|docx?|xlsx?|pptx?|zip|rar|7z|txt|rtf|csv|html?|apk|vcf)$/i.test(value); - const preferredLine = labelLines.find((line) => hasKnownExtension(line) && !isDownloadText(line)) || - labelLines.find((line) => !isDownloadText(line) && !/^\d{1,2}:\d{2}$/.test(line) && !/^\d+(?:[.,]\d+)?\s*(?:b|kb|mb|gb|\u0431|\u043a\u0431|\u043c\u0431|\u0433\u0431)$/i.test(line)) || - label; - const clean = cleanText(preferredLine, 120).replace(/[\\/:*?"<>|]+/g, " ").trim(); - const hasExtension = hasKnownExtension(clean); - const isFallback = /^((photo|image|video|audio|voice|gif)|(\u0444\u043e\u0442\u043e|\u0432\u0438\u0434\u0435\u043e|\u0430\u0443\u0434\u0438\u043e|\u0433\u043e\u043b\u043e\u0441))/i.test(clean) || - isDownloadText(clean) || - /(\u0431\u0440\u0430\u0443\u0437\u0435\u0440.*\u043d\u0435\s+\u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442|your browser.*support|not supported|\[object object\])/i.test(clean); - const embeddedFileName = clean.match(/(?:^|\s)([^\s\\/:*?"<>|]+\.[a-z0-9]{2,8})(?=\s|$)/i)?.[1]; - if (kind === "File" && embeddedFileName) { - return embeddedFileName.slice(0, 100); - } - if (clean && !isFallback && (kind === "File" || hasExtension)) { - return clean.slice(0, 100); - } - const ext = extensionFromUrl(url) || (kind === "Video" ? "mp4" : kind === "VoiceNote" ? "ogg" : kind === "Gif" ? "gif" : kind === "Sticker" ? "webp" : kind === "Contact" ? "vcf" : kind === "Image" ? "jpg" : "bin"); - return `max-${kind.toLowerCase()}-${index + 1}.${ext}`; - }; - const cssBackgroundUrl = (element) => { - const image = window.getComputedStyle(element).backgroundImage || ""; - const match = image.match(/url\(["']?(.+?)["']?\)/i); - return match ? absoluteUrl(match[1]) : null; - }; - const extractAvatarUrl = (root) => { - const candidates = Array.from(root.querySelectorAll("img, image, [style*='background' i], [class*='avatar' i], [class*='photo' i]")); - for (const node of candidates) { - if (!(node instanceof Element)) continue; - const rect = node.getBoundingClientRect(); - if (rect.width < 24 || rect.height < 24 || rect.width > 112 || rect.height > 112) continue; - const source = node.currentSrc || - node.src || - node.href?.baseVal || - node.getAttribute("src") || - node.getAttribute("href") || - cssBackgroundUrl(node); - const url = absoluteUrl(source); - if (url && !/pattern|sprite|emoji|sticker|logo/i.test(url)) { - return url; - } - } - return null; - }; - const deliveryStateFromMessage = (wrapper, bubble, isOutgoing) => { - if (!isOutgoing) return null; - const statusNodes = Array.from(wrapper.querySelectorAll("[aria-label], [title], [class*='mark' i], [class*='check' i], [class*='status' i], [class*='delivery' i], [class*='read' i], svg")); - const statusText = statusNodes.map((node) => [ - node.getAttribute?.("aria-label"), - node.getAttribute?.("title"), - node.getAttribute?.("data-testid"), - node.className?.baseVal || node.className, - node.textContent - ].filter(Boolean).join(" ")).join(" ").toLowerCase(); - const wrapperText = [ - wrapper.className, - bubble.className, - statusText - ].join(" ").toLowerCase(); - - if (/\u043f\u0440\u043e\u0447\u0438\u0442\u0430\u043d|\u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440|read|seen|viewed/.test(wrapperText)) { - return "Read"; - } - if (/\u0434\u043e\u0441\u0442\u0430\u0432|delivered|doneall|done_all|double|checks|check-double/.test(wrapperText)) { - return "Delivered"; - } - - if (/прочитан|прочитано|просмотр|read|seen|viewed/.test(wrapperText)) { - return "Read"; - } - if (/достав|delivered|doneall|done_all|double|checks|check-double/.test(wrapperText)) { - return "Delivered"; - } - return "Sent"; - }; - const collectAttachments = (bubble) => { - const candidates = []; - const contactRoots = []; - const contactNodes = Array.from(bubble.querySelectorAll("[class*='contact' i], [data-testid*='contact' i], [aria-label*='contact' i], [class*='phone' i], [data-testid*='phone' i], [href^='tel:']")); - const bubbleText = cleanText(bubble.innerText || bubble.textContent, 800); - const bubblePhone = phoneFromText(bubbleText); - const smallAvatar = Array.from(bubble.querySelectorAll("img, image, [style*='background' i], [class*='avatar' i]")) - .some((node) => { - const rect = node.getBoundingClientRect?.(); - if (!rect) return false; - const ratio = rect.height > 0 ? rect.width / rect.height : 0; - return rect.width >= 24 && rect.height >= 24 && rect.width <= 112 && rect.height <= 112 && ratio > 0.75 && ratio < 1.35; - }); - const bubbleLooksLikeContact = /contact|\u043a\u043e\u043d\u0442\u0430\u043a\u0442|\u0442\u0435\u043b\u0435\u0444\u043e\u043d|phone|mobile/i.test(bubbleText) || - (smallAvatar && bubblePhone); - if (bubbleLooksLikeContact) { - contactRoots.push(bubble); - } - for (const node of contactNodes) { - const root = node.closest("[class*='bubbleContent' i], [class*='message' i], [class*='contact' i], [role='button'], button, a") || node; - if (!contactRoots.includes(root)) { - contactRoots.push(root); - } - } - for (const root of contactRoots) { - const rootText = cleanText(root.innerText || root.textContent || root.getAttribute?.("aria-label"), 800); - const semantic = [ - root.getAttribute?.("aria-label"), - root.getAttribute?.("title"), - root.getAttribute?.("href"), - root.className?.baseVal || root.className - ].filter(Boolean).join(" "); - const phone = phoneFromText(root.getAttribute?.("href")) || phoneFromText(rootText); - if (!phone && !/contact|\u043a\u043e\u043d\u0442\u0430\u043a\u0442|\u0442\u0435\u043b\u0435\u0444\u043e\u043d|phone|mobile/i.test(`${rootText} ${semantic}`)) { - continue; - } - const lines = String(rootText || "") - .split(/\n+| {2,}/) - .map((line) => cleanText(line, 120)) - .filter(Boolean); - const name = lines.find((line) => - !phoneFromText(line) && - !isDownloadText(line) && - !/^(contact|\u043a\u043e\u043d\u0442\u0430\u043a\u0442|\u0442\u0435\u043b\u0435\u0444\u043e\u043d|phone|mobile)$/i.test(line) && - !/^\d{1,2}:\d{2}$/.test(line) - ) || "\u041a\u043e\u043d\u0442\u0430\u043a\u0442"; - const vcard = `BEGIN:VCARD\nVERSION:3.0\nFN:${vcardEscape(name)}\n${phone ? `TEL;TYPE=CELL:${phone}\n` : ""}END:VCARD\n`; - candidates.push({ - url: null, - label: name, - kind: "Contact", - fileName: safeContactFileName(name), - contentType: "text/vcard; charset=utf-8", - textContent: vcard - }); - } - - const voiceRoots = Array.from(bubble.querySelectorAll("[class*='attachAudio' i], [class*='voice' i], [class*='audio' i]")); - for (const root of voiceRoots) { - if (!(root instanceof HTMLElement)) continue; - if (contactRoots.some((contactRoot) => contactRoot !== root && contactRoot.contains?.(root))) continue; - const rootText = cleanText( - root.innerText || - root.textContent || - root.getAttribute?.("aria-label") || - root.getAttribute?.("title"), - 300); - const semantic = [ - root.className?.baseVal || root.className, - root.getAttribute?.("aria-label"), - root.getAttribute?.("title"), - rootText - ].filter(Boolean).join(" "); - if (!/attachaudio|voice|audio|\u0433\u043e\u043b\u043e\u0441|\u0430\u0443\u0434\u0438\u043e/i.test(semantic)) { - continue; - } - - const duration = durationFromText(rootText); - const wrapper = bubble.closest("[class*='messageWrapper' i]") || bubble; - const messageTimeText = messageTimeTextFromWrapper(wrapper); - const label = cleanText(`\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 ${duration}`.trim(), 120); - candidates.push({ - url: domAudioUrlFromVoice(label, duration, messageTimeText), - label, - kind: "VoiceNote", - fileName: safeVoiceFileName(duration, candidates.length), - contentType: "audio/ogg", - fileSizeBytes: null - }); - } - - const emojiSelector = "[data-testid*='emoji' i], [class*='emoji' i], [class*='lottie' i], [class*='emoticon' i], canvas"; - const emojiRoots = [ - ...(bubble.matches?.(emojiSelector) ? [bubble] : []), - ...Array.from(bubble.querySelectorAll(emojiSelector)) - ]; - const seenEmojiKeys = new Set(); - for (const root of emojiRoots) { - if (!(root instanceof HTMLElement)) continue; - if (contactRoots.some((contactRoot) => contactRoot !== root && contactRoot.contains?.(root))) continue; - if (root.closest?.("[data-testid^='sticker-'], [class*='sticker' i]")) continue; - - const rootText = cleanText( - root.innerText || - root.textContent || - root.getAttribute?.("aria-label") || - root.getAttribute?.("title"), - 300); - const semantic = [ - root.getAttribute?.("data-testid"), - root.getAttribute?.("aria-label"), - root.getAttribute?.("title"), - root.className?.baseVal || root.className, - rootText, - bubbleText - ].filter(Boolean).join(" "); - const hasVisual = root.matches("canvas,svg,img,image") || Boolean(root.querySelector("canvas,svg,img,image")); - const looksLikeEmoji = /emoji|\u044d\u043c\u043e\u0434\u0437\u0438|lottie|emoticon/i.test(semantic); - if (!looksLikeEmoji || !hasVisual) { - continue; - } - - const directNode = root.matches("img,image,source") - ? root - : root.querySelector("img,image,source"); - const directSource = directNode?.currentSrc || - directNode?.src || - directNode?.href?.baseVal || - directNode?.getAttribute?.("src") || - directNode?.getAttribute?.("href") || - directNode?.getAttribute?.("xlink:href") || - srcFromSrcset(directNode?.getAttribute?.("srcset")) || - cssBackgroundUrl(root); - const directUrl = absoluteUrl(directSource); - if (directUrl && !directUrl.startsWith("blob:")) { - continue; - } - - const wrapper = bubble.closest("[class*='messageWrapper' i]") || bubble; - const messageTimeText = messageTimeTextFromWrapper(wrapper); - const testId = root.getAttribute("data-testid") || - root.querySelector("[data-testid*='emoji' i], [data-testid*='lottie' i]")?.getAttribute("data-testid") || - ""; - const rect = root.getBoundingClientRect(); - if (rect.width < 12 || rect.height < 12) { - continue; - } - const emojiKey = testId || `${Math.round(rect.x)}:${Math.round(rect.y)}:${Math.round(rect.width)}:${Math.round(rect.height)}`; - if (seenEmojiKeys.has(emojiKey)) { - continue; - } - seenEmojiKeys.add(emojiKey); - const label = cleanText(root.getAttribute("aria-label") || rootText || "\u042d\u043c\u043e\u0434\u0437\u0438", 120); - candidates.push({ - url: domEmojiUrlFromCanvas(testId, messageTimeText, candidates.length), - label, - kind: "Sticker", - fileName: safeEmojiFileName(testId, candidates.length), - contentType: "image/png", - fileSizeBytes: null - }); - } - - const stickerRoots = [ - ...(bubble.matches?.("[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i]") ? [bubble] : []), - ...Array.from(bubble.querySelectorAll("[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i]")) - ]; - const seenStickerKeys = new Set(); - for (const root of stickerRoots) { - if (!(root instanceof HTMLElement)) continue; - if (contactRoots.some((contactRoot) => contactRoot !== root && contactRoot.contains?.(root))) continue; - const semantic = [ - root.getAttribute("data-testid"), - root.getAttribute("aria-label"), - root.getAttribute("title"), - root.className, - root.innerText || root.textContent - ].filter(Boolean).join(" "); - const hasCanvas = root.matches("canvas") || Boolean(root.querySelector("canvas")); - if (!hasCanvas && !/sticker|\u0441\u0442\u0438\u043a\u0435\u0440/i.test(semantic)) { - continue; - } - - const wrapper = bubble.closest("[class*='messageWrapper' i]") || bubble; - const messageTimeText = messageTimeTextFromWrapper(wrapper); - const testId = root.getAttribute("data-testid") || - root.querySelector("[data-testid^='sticker-']")?.getAttribute("data-testid") || - ""; - const rect = root.getBoundingClientRect(); - const stickerKey = testId || `${Math.round(rect.x)}:${Math.round(rect.y)}:${Math.round(rect.width)}:${Math.round(rect.height)}`; - if (seenStickerKeys.has(stickerKey)) { - continue; - } - seenStickerKeys.add(stickerKey); - const label = cleanText(root.getAttribute("aria-label") || root.innerText || root.textContent || "\u0421\u0442\u0438\u043a\u0435\u0440", 120); - candidates.push({ - url: domStickerUrlFromCanvas(testId, messageTimeText, candidates.length), - label, - kind: "Sticker", - fileName: safeStickerFileName(testId, candidates.length), - contentType: "image/png", - fileSizeBytes: null - }); - } - - const fileButtons = Array.from(bubble.querySelectorAll("button,[role='button']")); - for (const node of fileButtons) { - if (!(node instanceof HTMLElement)) continue; - if (contactRoots.some((root) => root !== node && root.contains?.(node))) continue; - const label = cleanText( - node.innerText || - node.textContent || - node.getAttribute("aria-label") || - node.getAttribute("title"), - 300); - const looksLikeFile = /\.[a-z0-9]{2,8}\b/i.test(label) && - /download|\u0441\u043a\u0430\u0447\u0430\u0442\u044c|\u0444\u0430\u0439\u043b|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\d+(?:[.,]\d+)?\s*(?:b|kb|mb|gb|\u0431|\u043a\u0431|\u043c\u0431|\u0433\u0431)\b/i.test(label); - if (!looksLikeFile) continue; - const fileName = fileNameFromMedia(label, "File", "", candidates.length); - if (!/\.[a-z0-9]{2,8}$/i.test(fileName)) continue; - candidates.push({ - url: domDownloadUrlFromFile(label, fileName), - label, - kind: "File", - fileName, - contentType: contentTypeFromKind("File", fileName), - fileSizeBytes: fileSizeBytesFromLabel(label) - }); - } - - const mediaSelector = "img, image, picture, video, audio, source, a[href], button, [role='button'], [download], [style*='background' i], [class*='sticker' i], [class*='emoji' i], [class*='media' i], [class*='photo' i], [class*='image' i], [class*='file' i], [class*='document' i], [class*='attachment' i], [class*='download' i]"; - const nodes = [ - ...(bubble.matches?.(mediaSelector) ? [bubble] : []), - ...Array.from(bubble.querySelectorAll(mediaSelector)) - ]; - for (const node of nodes) { - if (contactRoots.some((root) => root !== node && root.contains?.(node))) continue; - const tagName = String(node.tagName || "").toLowerCase(); - const closestLink = node.closest?.("a[href]"); - const nestedLink = node.querySelector?.("a[href]"); - let rawUrl = node.currentSrc || - node.src || - node.poster || - node.href?.baseVal || - node.href || - node.dataset?.url || - node.dataset?.href || - node.dataset?.downloadUrl || - node.dataset?.fileUrl || - node.getAttribute("src") || - node.getAttribute("href") || - node.getAttribute("xlink:href") || - node.getAttribute("data-url") || - node.getAttribute("data-href") || - node.getAttribute("data-download-url") || - node.getAttribute("data-file-url") || - node.getAttribute("download-url") || - nestedLink?.href || - closestLink?.href || - srcFromSrcset(node.getAttribute("srcset")); - if (!rawUrl && tagName === "picture") { - const source = node.querySelector("source,img"); - rawUrl = source?.currentSrc || - source?.src || - source?.getAttribute("src") || - srcFromSrcset(source?.getAttribute("srcset")); - } - let url = absoluteUrl(rawUrl); - if (!url) { - url = cssBackgroundUrl(node); - } - if (!url) continue; - const rect = node.getBoundingClientRect?.(); - const importantSmallMedia = isEmojiUrl(url) || isStickerUrl(url); - if (rect && !importantSmallMedia && (rect.width < 18 || rect.height < 18)) continue; - if (rect && importantSmallMedia && (rect.width < 8 || rect.height < 8)) continue; - const labelRoot = node.closest?.("[class*='file' i], [class*='document' i], [class*='attachment' i], [class*='download' i], [role='button'], button, a") || node; - const label = cleanText( - labelRoot.getAttribute?.("aria-label") || - labelRoot.getAttribute?.("alt") || - labelRoot.getAttribute?.("title") || - labelRoot.getAttribute?.("download") || - labelRoot.innerText || - node.getAttribute("aria-label") || - node.getAttribute("alt") || - node.getAttribute("title") || - node.getAttribute("download") || - node.textContent, - 160); - if (/profile|avatar|\u0430\u0432\u0430\u0442\u0430\u0440/i.test(label)) continue; - const semantic = [ - tagName, - node.getAttribute?.("role"), - node.getAttribute?.("aria-label"), - node.getAttribute?.("title"), - node.className?.baseVal || node.className, - label - ].filter(Boolean).join(" "); - const mediaTag = tagName === "source" ? String(node.parentElement?.tagName || "").toLowerCase() : tagName; - const kind = kindFromMedia(mediaTag, url, `${label} ${semantic}`); - const fileLike = tagName === "a" || - tagName === "button" || - /file|document|attachment|download|\u0444\u0430\u0439\u043b|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u043a\u0430\u0447\u0430\u0442\u044c/i.test(semantic); - if (kind === "File" && !fileLike) continue; - candidates.push({ url, label, kind }); - } - - const seenUrls = new Set(); - return candidates - .filter((candidate) => { - const identity = candidate.url || `${candidate.kind}:${candidate.fileName || candidate.label}:${candidate.textContent || ""}`; - if (seenUrls.has(identity)) return false; - seenUrls.add(identity); - return true; - }) - .slice(0, 8) - .map((candidate, index) => ({ - externalId: `${candidate.kind}:${index}:${candidate.url || candidate.fileName || candidate.label}`, - fileName: candidate.fileName || fileNameFromMedia(candidate.label, candidate.kind, candidate.url, index), - contentType: candidate.contentType || contentTypeFromKind(candidate.kind, candidate.url), - fileSizeBytes: candidate.textContent ? new Blob([candidate.textContent]).size : candidate.fileSizeBytes ?? null, - remoteUrl: candidate.url, - kind: candidate.kind, - sortOrder: index, - textContent: candidate.textContent || null - })); - }; - - const existingWrappers = Array.from(opened.querySelectorAll("[class*='messageWrapper' i]")) - .filter(isVisible); - const standaloneMediaRoots = Array.from(opened.querySelectorAll("img, image, picture, video, canvas, svg, [class*='sticker' i], [class*='emoji' i], [class*='lottie' i]")) - .filter(isVisible) - .map((node) => node.closest?.("[class*='messageWrapper' i], [class*='item' i], [class*='bubbleContent' i], [class*='bordersWrapper' i], [class*='attach' i], [class*='sticker' i], [class*='emoji' i]") || node) - .filter((node) => node instanceof HTMLElement) - .filter((node) => { - if (existingWrappers.some((wrapper) => wrapper === node || wrapper.contains(node) || node.contains(wrapper))) { - return false; - } - if (node.closest?.("aside, header, [class*='header' i], [class*='composer' i], [class*='toolbar' i]")) { - return false; - } - const rect = node.getBoundingClientRect(); - return rect.width >= 24 && - rect.height >= 24 && - rect.left > openedRect.left + 8 && - rect.top > openedRect.top + 48 && - rect.bottom < window.innerHeight - 24; - }); - const wrappers = [...existingWrappers, ...standaloneMediaRoots] - .filter((wrapper, index, all) => all.findIndex((other) => other === wrapper || other.contains?.(wrapper)) === index) - .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top); - const messages = []; - const seen = new Set(); - for (const wrapper of wrappers) { - const bubble = wrapper.querySelector("[class*='bubbleContent' i]") || wrapper; - const item = wrapper.closest("[class*='item' i]"); - const dateLabel = cleanText(item?.querySelector("[class*='capsule' i]")?.innerText, 80); - const timeText = messageTimeTextFromWrapper(wrapper); - const textParts = Array.from(bubble.querySelectorAll("span[class*='text' i], div[class*='text' i]")) - .filter((node) => !node.closest("[class*='meta' i]")) - .filter((node) => !node.closest("[class*='mark' i]")) - .filter((node) => !node.closest("[class*='link' i]")) - .filter((node) => !node.closest("button[class*='author' i]")) - .filter((node) => !String(node.className || "").toLowerCase().includes("placeholder")) - .map((node) => cleanText(node.innerText || node.textContent, 2000)) - .filter((text) => text && !/^\d{1,2}:\d{2}$/.test(text)); - let text = Array.from(new Set(textParts)).join("\n").trim(); - if (!text) { - const media = bubble.querySelector("[aria-label]"); - const mediaLabel = cleanText(media?.getAttribute("aria-label"), 120); - if (mediaLabel && !/profile/i.test(mediaLabel)) { - text = mediaLabel; - } - } - const attachments = collectAttachments(bubble); - if (isGenericMediaText(text)) { - if (attachments.length > 0) { - text = ""; - } else { - continue; - } - } - if ((!text || /^\d{1,2}:\d{2}$/.test(text)) && attachments.length === 0) { - continue; - } - if (/^\d{1,2}:\d{2}$/.test(text)) { - text = ""; - } - - const rect = bubble.getBoundingClientRect(); - const classText = [ - wrapper.className, - bubble.className, - wrapper.querySelector("[class*='bordersWrapper' i]")?.className - ].join(" "); - const isOutgoing = /--right|outgoing|own|self/i.test(classText) || - rect.left > openedRect.left + openedRect.width * 0.45; - const deliveryState = deliveryStateFromMessage(wrapper, bubble, isOutgoing); - const attachmentKey = attachments.map((attachment) => attachment.externalId).join(","); - const key = `${text}|${attachmentKey}|${timeText}|${dateLabel}|${isOutgoing}|${Math.round(rect.y)}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - messages.push({ - text, - timeText, - dateLabel, - isOutgoing, - deliveryState, - sentAt: parseDate(dateLabel, timeText), - rect: { - x: Math.round(rect.x), - y: Math.round(rect.y), - width: Math.round(rect.width), - height: Math.round(rect.height) - }, - attachments - }); - } - - return { - url: location.href, - title, - avatarUrl: extractAvatarUrl(header || opened), - kind: classifyOpenChatKind(), - messages - }; - }, requestedTitle); - - const externalId = requestedExternalChatId || makeDomChatExternalId(raw.title, "", null, 0); - const now = new Date().toISOString(); - const externalIdOrdinals = new Map(); - const sentAtOffsets = new Map(); - const orderedSentAt = (sentAt) => { - const base = sentAt || now; - const offset = sentAtOffsets.get(base) || 0; - sentAtOffsets.set(base, offset + 1); - if (offset === 0) { - return base; - } - - const date = new Date(base); - if (Number.isNaN(date.getTime())) { - return base; - } - - date.setMilliseconds(date.getMilliseconds() + offset); - return date.toISOString(); - }; - return { - externalId, - title: raw.title || requestedTitle || "MAX chat", - avatarUrl: raw.avatarUrl || null, - kind: raw.kind || null, - updatedAt: now, - messages: raw.messages.map((message) => { - const baseExternalId = stableHash(externalId, message.text, message.timeText, message.sentAt, message.isOutgoing); - const ordinal = externalIdOrdinals.get(baseExternalId) || 0; - externalIdOrdinals.set(baseExternalId, ordinal + 1); - const messageExternalId = ordinal === 0 ? `domhist:${baseExternalId}` : `domhist:${baseExternalId}:${ordinal}`; - return { - externalId: messageExternalId, - senderExternalId: message.isOutgoing ? "self" : externalId, - senderName: message.isOutgoing ? null : (raw.title || requestedTitle || null), - isOutgoing: message.isOutgoing, - text: message.text, - sentAt: orderedSentAt(message.sentAt), - deliveryState: message.deliveryState || null, - rect: message.rect || null, - attachments: (message.attachments || []).map((attachment, index) => { - const remoteUrl = attachDomMediaChatContext(attachment.remoteUrl, externalId, raw.url); - return { - ...attachment, - remoteUrl, - externalId: `dommedia:${stableHash(externalId, messageExternalId, remoteUrl || attachment.externalId || "", String(index))}` - }; - }) - }; - }) - }; -} - -async function extractOpenChatPresence(p) { - return await p.evaluate(() => { - const cleanText = (value, limit = 500) => String(value || "") - .replace(/\u00a0/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, limit); - const opened = document.querySelector("[class*='openedChat' i]") || - document.querySelector("main") || - document.body; - const header = opened.querySelector("[class*='header' i]") || opened; - const candidates = Array.from(header.querySelectorAll("[class*='subtitle' i], [class*='status' i], [class*='typing' i], span, div")) - .map((node) => cleanText(node.innerText || node.textContent, 160)) - .filter(Boolean); - const bodyCandidates = Array.from(opened.querySelectorAll("[class*='typing' i], [aria-label*='typing' i], [aria-label*='\u043f\u0435\u0447\u0430\u0442' i]")) - .map((node) => cleanText( - node.innerText || - node.textContent || - node.getAttribute("aria-label") || - node.getAttribute("title"), - 160)) - .filter(Boolean); - const all = [...candidates, ...bodyCandidates]; - const statusText = all.find((text) => - /(\u043f\u0435\u0447\u0430\u0442|\u043d\u0430\u0431\u0438\u0440\u0430|\u0437\u0430\u043f\u0438\u0441\u044b\u0432\u0430|typing|recording)/i.test(text) - ) || null; - - return { - isTyping: Boolean(statusText), - statusText, - updatedAt: new Date().toISOString() - }; - }); -} - -async function clearSidebarSearch(p) { - await p.evaluate(() => { - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const setNativeValue = (element, value) => { - const descriptor = Object.getOwnPropertyDescriptor(element.constructor.prototype, "value"); - if (descriptor?.set) { - descriptor.set.call(element, value); - } else { - element.value = value; - } - }; - const inputs = Array.from(document.querySelectorAll("input, textarea, [contenteditable='true'], [role='textbox']")) - .filter(isVisible) - .filter((element) => { - const rect = element.getBoundingClientRect(); - const text = [ - element.getAttribute("aria-label"), - element.getAttribute("placeholder"), - element.getAttribute("title"), - element.getAttribute("data-testid"), - element.className, - element.id, - element.getAttribute("name"), - element.value - ].join(" ").toLowerCase(); - const isSearchLike = /search|\u043f\u043e\u0438\u0441\u043a/.test(text); - const isTopSidebarInput = rect.left < window.innerWidth * 0.42 && - rect.top < window.innerHeight * 0.34; - return rect.left < window.innerWidth * 0.42 && - rect.top < window.innerHeight * 0.32 && - (isSearchLike || isTopSidebarInput); - }); - - for (const input of inputs) { - if ("value" in input) { - if (!input.value) continue; - setNativeValue(input, ""); - } else { - if (!input.textContent) continue; - input.textContent = ""; - } - input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null })); - input.dispatchEvent(new Event("change", { bubbles: true })); - } - }).catch(() => {}); - await p.waitForTimeout(150); -} - -async function resetSidebarListToTop(p) { - await p.evaluate(() => { - const aside = document.querySelector("aside"); - if (!aside) return; - - const candidates = [ - aside, - ...Array.from(aside.querySelectorAll("[class*='scroll' i], [class*='contentOverflow' i], [class*='cropped' i], [class*='list' i]")) - ].filter((element) => element instanceof HTMLElement); - - for (const element of candidates) { - if (element.scrollHeight <= element.clientHeight + 2) continue; - element.scrollTop = 0; - element.dispatchEvent(new Event("scroll", { bubbles: true })); - } - }).catch(() => {}); - await p.waitForTimeout(250); -} - -async function selectSidebarFilter(p, labels) { - const clicked = await p.evaluate((rawLabels) => { - const normalize = (value) => String(value || "") - .replace(/\u00a0/g, " ") - .replace(/\s+/g, " ") - .trim() - .toLowerCase(); - const wanted = rawLabels.map(normalize).filter(Boolean); - if (wanted.length === 0) { - return false; - } - - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const textOf = (element) => normalize( - element.innerText || - element.textContent || - element.getAttribute("aria-label") || - element.getAttribute("title") || - "" - ); - const candidates = Array.from(document.querySelectorAll([ - "nav button", - "nav [role='button']", - "nav a", - "nav span", - "nav div", - "aside button", - "aside [role='button']", - "aside a", - "aside span", - "aside div" - ].join(","))) - .filter((element) => element instanceof HTMLElement && isVisible(element)) - .map((element) => { - const rect = element.getBoundingClientRect(); - const text = textOf(element); - const firstLine = normalize(text.split("\n")[0]); - const exact = wanted.some((label) => text === label || firstLine === label); - const compact = text.length <= 30 && wanted.some((label) => text.includes(label)); - const topSidebar = rect.left < window.innerWidth * 0.46 && rect.top < window.innerHeight * 0.32; - const clickable = element.closest("button, [role='button'], a") || element; - return { element: clickable, rect, exact, compact, topSidebar, text }; - }) - .filter((item) => item.topSidebar && (item.exact || item.compact)) - .sort((a, b) => - Number(b.exact) - Number(a.exact) || - a.rect.top - b.rect.top || - a.rect.left - b.rect.left); - - const match = candidates[0]?.element; - if (!match) { - return false; - } - - match.click(); - return true; - }, labels).catch(() => false); - - if (clicked) { - await p.waitForTimeout(450); - } - - return clicked; -} - -async function focusSidebarSearch(p) { - const focused = await p.evaluate(async () => { - const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const textMeta = (element) => [ - element.getAttribute("aria-label"), - element.getAttribute("placeholder"), - element.getAttribute("title"), - element.getAttribute("data-testid"), - element.className, - element.id, - element.getAttribute("name"), - element.innerText, - element.textContent - ].join(" ").toLowerCase(); - const setNativeValue = (element, value) => { - const descriptor = Object.getOwnPropertyDescriptor(element.constructor.prototype, "value"); - if (descriptor?.set) { - descriptor.set.call(element, value); - } else { - element.value = value; - } - }; - const findInput = () => { - const aside = document.querySelector("aside") || document.body; - return Array.from(aside.querySelectorAll("input, textarea, [contenteditable='true'], [role='textbox']")) - .filter(isVisible) - .filter((element) => { - const rect = element.getBoundingClientRect(); - const text = textMeta(element); - const isSearchLike = /search|\u043f\u043e\u0438\u0441\u043a/.test(text); - const isTopSidebarInput = rect.left < window.innerWidth * 0.42 && - rect.top < window.innerHeight * 0.34; - return isSearchLike || isTopSidebarInput; - }) - .sort((a, b) => { - const aSearch = /search|\u043f\u043e\u0438\u0441\u043a/.test(textMeta(a)) ? 0 : 1; - const bSearch = /search|\u043f\u043e\u0438\u0441\u043a/.test(textMeta(b)) ? 0 : 1; - if (aSearch !== bSearch) return aSearch - bSearch; - return a.getBoundingClientRect().top - b.getBoundingClientRect().top; - })[0] || null; - }; - let input = findInput(); - if (!input) { - const aside = document.querySelector("aside") || document.body; - const button = Array.from(aside.querySelectorAll("button, [role='button']")) - .filter(isVisible) - .find((element) => /search|\u043f\u043e\u0438\u0441\u043a/.test(textMeta(element))); - if (button) { - button.click(); - await delay(200); - input = findInput(); - } - } - if (!input) return false; - - input.focus(); - if ("value" in input) { - setNativeValue(input, ""); - } else { - input.textContent = ""; - } - input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null })); - input.dispatchEvent(new Event("change", { bubbles: true })); - return true; - }).catch(() => false); - - if (focused) { - await p.waitForTimeout(100); - } - return Boolean(focused); -} - -async function searchSidebarChat(p, title) { - await clearSidebarSearch(p); - const focused = await focusSidebarSearch(p); - if (!focused) { - return false; - } - - await p.keyboard.insertText(title); - await p.waitForTimeout(700); - return true; -} - -async function waitForMaxChatListReady(p, timeoutMs = 15000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const ready = await p.evaluate(() => { - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 20 && - rect.height > 12 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - - return Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i], aside button.item, aside button[class*='item' i]")) - .some(isVisible); - }).catch(() => false); - - if (ready) { - return true; - } - - await p.waitForTimeout(250); - } - - return false; -} - -async function readVisibleSidebarChatRows(p) { - return await p.evaluate(() => { - const cleanText = (value, limit = 500) => String(value || "") - .replace(/\u00a0/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, limit); - const linesFrom = (value) => String(value || "") - .split(/\n+/) - .map((line) => cleanText(line, 180)) - .filter(Boolean); - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 20 && - rect.height > 12 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const absoluteUrl = (value) => { - const raw = String(value || "").trim(); - if (!raw || raw.startsWith("data:")) return null; - try { - return new URL(raw, location.href).href; - } catch { - return null; - } - }; - const cssBackgroundUrl = (element) => { - const image = window.getComputedStyle(element).backgroundImage || ""; - const match = image.match(/url\(["']?(.+?)["']?\)/i); - return match ? absoluteUrl(match[1]) : null; - }; - const extractAvatarUrl = (element) => { - const rowRect = element.getBoundingClientRect(); - const candidates = Array.from(element.querySelectorAll("img, image, [style*='background' i], [class*='avatar' i], [class*='photo' i]")); - for (const node of candidates) { - if (!(node instanceof Element)) continue; - const rect = node.getBoundingClientRect(); - if (rect.width < 24 || rect.height < 24 || rect.width > 96 || rect.height > 96) continue; - if (rect.left > rowRect.left + rowRect.width * 0.45) continue; - const source = node.currentSrc || - node.src || - node.href?.baseVal || - node.getAttribute("src") || - node.getAttribute("href") || - cssBackgroundUrl(node); - const url = absoluteUrl(source); - if (url && !/pattern|sprite|emoji|sticker|logo/i.test(url)) { - return url; - } - } - return null; - }; - const extractChatUrl = (element) => { - const candidates = [ - element.getAttribute("href"), - element.closest("a")?.getAttribute("href"), - element.getAttribute("data-url"), - element.getAttribute("data-href"), - element.getAttribute("data-link"), - element.getAttribute("data-path") - ]; - for (const attr of Array.from(element.attributes || [])) { - if (/url|href|link|path/i.test(attr.name)) { - candidates.push(attr.value); - } - } - - for (const candidate of candidates) { - const url = absoluteUrl(candidate); - if (url) { - return url; - } - } - - return null; - }; - const extractTitle = (element, lines) => { - const titleNode = element.querySelector("h3, [class*='title' i] [class*='name' i], [class*='name' i]"); - const title = cleanText(titleNode?.innerText || titleNode?.textContent, 160); - return title || lines.find((line) => line && !/^\d{1,3}$/.test(line)) || ""; - }; - const extractPreview = (element, title, lines) => { - const timeText = cleanText(element.querySelector("[class*='time' i], [class*='meta' i]")?.innerText, 80); - const ignored = new Set([ - title, - timeText, - cleanText(element.querySelector("[class*='badge' i], [class*='indicator' i], [class*='counter' i]")?.innerText, 80) - ].filter(Boolean)); - return lines.find((line) => line !== title && !ignored.has(line) && !/^\d{1,3}$/.test(line)) || null; - }; - const extractTimeText = (element, lines) => { - const direct = cleanText(element.querySelector("[class*='time' i], [class*='meta' i]")?.innerText, 80); - if (direct) return direct; - return lines.find((line) => - /^\d{1,2}:\d{2}$/.test(line) || - /^(\u0432\u0447\u0435\u0440\u0430|\u0441\u0435\u0433\u043e\u0434\u043d\u044f)$/i.test(line) || - /^\d{1,2}\s+[^\s.]+\.?$/i.test(line) || - /^(mon|tue|wed|thu|fri|sat|sun|\u043f\u043d|\u0432\u0442|\u0441\u0440|\u0447\u0442|\u043f\u0442|\u0441\u0431|\u0432\u0441)$/i.test(line) - ) || null; - }; - const textOf = (element) => String( - element.innerText || - element.textContent || - element.getAttribute("aria-label") || - element.getAttribute("title") || - "" - ).trim().toLowerCase(); - const isMenuButton = (element) => { - const text = textOf(element); - const cls = String(element.className || "").toLowerCase(); - return text.includes("\u0435\u0449") || - text.includes("more") || - text.includes("menu") || - text.includes("actions") || - text.includes("\u0434\u0435\u0439\u0441\u0442\u0432") || - cls.includes("more") || - cls.includes("menu") || - text === "\u22ef" || - text === "\u2026"; - }; - const menuPoint = (element) => { - const buttons = Array.from(element.querySelectorAll("button,[role='button']")) - .filter((button) => button instanceof HTMLElement && isVisible(button)); - const match = buttons.find(isMenuButton) || - buttons.sort((a, b) => b.getBoundingClientRect().right - a.getBoundingClientRect().right)[0] || - null; - if (!match) return null; - const rect = match.getBoundingClientRect(); - return { - x: Math.round(rect.left + rect.width / 2), - y: Math.round(rect.top + rect.height / 2) - }; - }; - const selector = [ - "aside [role='presentation']", - "aside [class*='wrapper' i]", - "aside button.cell", - "aside button[class*='cell' i]", - "aside button.item", - "aside button[class*='item' i]", - "aside [role='presentation'] button", - "aside [class*='wrapper' i] button", - "aside [data-testid*='chat' i]", - "aside [data-testid*='dialog' i]", - "aside [data-testid*='conversation' i]" - ].join(","); - - return Array.from(document.querySelectorAll(selector)) - .filter(isVisible) - .map((element) => { - const rect = element.getBoundingClientRect(); - const text = cleanText(element.innerText || element.textContent, 500); - const lines = linesFrom(element.innerText || element.textContent); - const title = extractTitle(element, lines); - const avatarUrl = extractAvatarUrl(element); - const href = extractChatUrl(element); - const menu = menuPoint(element); - return { - title, - text, - preview: extractPreview(element, title, lines), - timeText: extractTimeText(element, lines), - avatarUrl, - href, - left: Math.round(rect.left), - right: Math.round(rect.right), - x: Math.round(rect.left + Math.min(rect.width - 8, Math.max(8, rect.width * 0.50))), - y: Math.round(rect.top + rect.height / 2), - menuX: menu?.x || null, - menuY: menu?.y || null, - top: Math.round(rect.top) - }; - }) - .filter((row) => row.title && row.text) - .filter((row, index, rows) => - rows.findIndex((candidate) => candidate.title === row.title && Math.abs(candidate.top - row.top) <= 2) === index) - .sort((a, b) => a.top - b.top); - }).catch(() => []); -} - -async function scrollSidebarForward(p) { - return await p.evaluate(() => { - const scrollRoot = Array.from(document.querySelectorAll("aside, aside *")) - .filter((element) => element instanceof HTMLElement) - .filter((element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.display !== "none" && - style.visibility !== "hidden" && - rect.width > 180 && - rect.height > 160 && - element.scrollHeight > element.clientHeight + 80; - }) - .sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0] || null; - - if (!scrollRoot) return false; - const bottom = Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight); - if (scrollRoot.scrollTop >= bottom - 4) return false; - const nextTop = Math.min(bottom, scrollRoot.scrollTop + Math.max(220, Math.floor(scrollRoot.clientHeight * 0.85))); - if (nextTop === scrollRoot.scrollTop) return false; - scrollRoot.scrollTop = nextTop; - scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); - return true; - }).catch(() => false); -} - -function normalizeDomTitle(value) { - return cleanComparableText(value, 160).toLowerCase(); -} - -function rowMatchesDomChatDescriptor(row, descriptor) { - const wanted = normalizeDomTitle(descriptor?.title); - const current = normalizeDomTitle(row?.title); - if (!wanted || !current) { - return false; - } - - if (current === wanted) { - return true; - } - - if (Math.min(wanted.length, current.length) < 4) { - return false; - } - - return areComparableDomTitles(wanted, current) && (current.includes(wanted) || wanted.includes(current)); -} - -function areComparableDomTitles(left, right) { - const minLength = Math.min(left.length, right.length); - const maxLength = Math.max(left.length, right.length); - return maxLength > 0 && minLength / maxLength >= 0.72; -} - -function chooseDomChatRow(rows, descriptor) { - const matches = rows.filter((row) => rowMatchesDomChatDescriptor(row, descriptor)); - if (matches.length === 0) { - return null; - } - - const expectedAvatar = normalizeDomChatFingerprint(descriptor?.avatarUrl); - if (expectedAvatar) { - const strict = matches.filter((row) => normalizeDomChatFingerprint(row.avatarUrl) === expectedAvatar); - if (strict.length > 0) { - return strict[0]; - } - } - - return matches.length === 1 ? matches[0] : null; -} - -async function clickMatchingSidebarRowAndReadUrl(p, descriptor) { - const row = chooseDomChatRow(await readVisibleSidebarChatRows(p), descriptor); - if (!row) { - return null; - } - - await p.mouse.click(row.x, row.y); - await waitForDomChatReady(p, descriptor.title, 5000, false); - await p.waitForTimeout(150); - return await getCurrentChatUrl(p); -} - -async function openSidebarChatMenu(p, externalChatId, chatUrl, expectedLabels, allowMenuWithoutExpectedLabels = false) { - const descriptor = decodeDomChatDescriptor(externalChatId); - if (!descriptor?.title) { - return false; - } - - const targetUrl = normalizeMaxChatUrl(chatUrl); - const sameChatUrl = (value) => { - const current = normalizeMaxChatUrl(value); - return Boolean(targetUrl && current && current === targetUrl); - }; - - if (targetUrl && await openDomChatByUrl(p, targetUrl)) { - await waitForDomChatReady(p, descriptor.title, 5000, false); - await clearSidebarSearch(p); - if (await openSelectedSidebarChatMenu(p, expectedLabels, allowMenuWithoutExpectedLabels)) { - return true; - } - } - - const chooseVisibleRow = async () => { - const rows = await readVisibleSidebarChatRows(p); - const matches = rows.filter((row) => rowMatchesDomChatDescriptor(row, descriptor)); - if (matches.length === 0) { - return { row: null, rows }; - } - - const fallbackRow = chooseDomChatRow(rows, descriptor); - if (targetUrl) { - for (const candidate of matches) { - await p.mouse.click(candidate.x, candidate.y); - await waitForDomChatReady(p, candidate.title, 5000, false); - await p.waitForTimeout(150); - if (sameChatUrl(await getCurrentChatUrl(p))) { - return { row: candidate, rows }; - } - } - - if (fallbackRow) { - console.warn(`[chat/menu] URL did not match ${targetUrl}; falling back to sidebar row title=${fallbackRow.title}`); - } - return { row: fallbackRow, rows }; - } - - return { row: fallbackRow, rows }; - }; - - const clickVisibleRowMenu = async () => { - const { row, rows } = await chooseVisibleRow(); - if (!row) { - return false; - } - - const x = row.menuX || (row.right ? row.right - 32 : row.x); - const y = row.menuY || row.y; - console.info(`[chat/menu] matched sidebar row title=${row.title} menu=(${x},${y}) avatar=${row.avatarUrl || ""} visibleRows=${rows.length}`); - await p.mouse.move(x, y); - await p.waitForTimeout(150); - await p.mouse.click(x, y); - await p.waitForTimeout(600); - const opened = await hasMenuActionSurface(p, expectedLabels) || - (allowMenuWithoutExpectedLabels && await hasAnyChatMenuSurface(p)); - if (!opened) { - const menuTexts = await readVisibleActionTexts(p); - console.warn(`[chat/menu] expected action labels not found; labels=${expectedLabels.join("|")} visible=${menuTexts.slice(0, 20).join("|")}`); - await p.keyboard.press("Escape").catch(() => {}); - await p.waitForTimeout(150); - } - return opened; - }; - - await clearSidebarSearch(p); - await resetSidebarListToTop(p); - await waitForMaxChatListReady(p, 15000); - if (await clickVisibleRowMenu()) { - return true; - } - - const searched = await searchSidebarChat(p, descriptor.title); - if (searched && await clickVisibleRowMenu()) { - return true; - } - - await clearSidebarSearch(p); - await resetSidebarListToTop(p); - await waitForMaxChatListReady(p, 15000); - - for (let pageIndex = 0; pageIndex < 220; pageIndex++) { - if (await clickVisibleRowMenu()) { - return true; - } - - const moved = await scrollSidebarForward(p); - if (!moved) break; - await p.waitForTimeout(250); - } - - await clearSidebarSearch(p); - return false; -} - -async function openSelectedSidebarChatMenu(p, expectedLabels, allowMenuWithoutExpectedLabels = false) { - for (let attempt = 0; attempt < 3; attempt++) { - const point = await p.evaluate(() => { - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const textOf = (element) => String( - element.innerText || - element.textContent || - element.getAttribute("aria-label") || - element.getAttribute("title") || - "" - ).trim().toLowerCase(); - const isMenuButton = (element) => { - const text = textOf(element); - const cls = String(element.className || "").toLowerCase(); - return text.includes("\u0435\u0449") || - text.includes("more") || - text.includes("menu") || - text.includes("actions") || - cls.includes("menubutton") || - cls.includes("menu") || - text === "\u22ef" || - text === "\u2026"; - }; - const aside = document.querySelector("aside"); - if (!aside) { - return null; - } - - const selected = Array.from(aside.querySelectorAll([ - "button[class*='selected' i]", - "[aria-selected='true']", - "[class*='selected' i]" - ].join(","))) - .filter((element) => element instanceof HTMLElement && isVisible(element)) - .map((element) => - element.closest("[class*='wrapper' i], [class*='item' i], button[class*='cell' i]") || - element) - .find((element) => element instanceof HTMLElement && isVisible(element)); - if (!selected) { - return null; - } - - const buttons = Array.from(selected.querySelectorAll("button,[role='button']")) - .filter((button) => button instanceof HTMLElement && isVisible(button)); - const menuButton = buttons.find(isMenuButton) || - buttons.sort((a, b) => b.getBoundingClientRect().right - a.getBoundingClientRect().right)[0] || - null; - if (!menuButton) { - return null; - } - - const rect = menuButton.getBoundingClientRect(); - return { - x: Math.round(rect.left + rect.width / 2), - y: Math.round(rect.top + rect.height / 2), - title: String(selected.innerText || selected.textContent || "").replace(/\s+/g, " ").trim().slice(0, 160) - }; - }).catch(() => null); - - if (!point) { - return false; - } - - console.info(`[chat/menu] using selected sidebar row title=${point.title} menu=(${point.x},${point.y})`); - await p.mouse.move(point.x, point.y); - await p.waitForTimeout(150); - await p.mouse.click(point.x, point.y); - await p.waitForTimeout(600); - if (await hasMenuActionSurface(p, expectedLabels) || - (allowMenuWithoutExpectedLabels && await hasAnyChatMenuSurface(p))) { - return true; - } - - const menuTexts = await readVisibleActionTexts(p); - console.warn(`[chat/menu] selected row menu did not expose expected action; visible=${menuTexts.slice(0, 20).join("|")}`); - await p.keyboard.press("Escape").catch(() => {}); - await p.waitForTimeout(250); - } - - return false; -} - -async function resolveDomChatUrl(p, externalChatId) { - const descriptor = decodeDomChatDescriptor(externalChatId); - if (!descriptor?.title) { - return null; - } - - const embeddedUrl = normalizeMaxChatUrl(descriptor.href); - if (embeddedUrl) { - return embeddedUrl; - } - - const opened = await ensureDomChatOpen(p, externalChatId, 5000, false, null); - if (opened) { - const currentUrl = await getCurrentChatUrl(p); - if (currentUrl) { - return currentUrl; - } - } - - await clearSidebarSearch(p); - await resetSidebarListToTop(p); - await waitForMaxChatListReady(p, 15000); - - let chatUrl = await clickMatchingSidebarRowAndReadUrl(p, descriptor); - if (chatUrl) { - await clearSidebarSearch(p); - return chatUrl; - } - - const searched = await searchSidebarChat(p, descriptor.title); - if (searched) { - chatUrl = await clickMatchingSidebarRowAndReadUrl(p, descriptor); - if (chatUrl) { - await clearSidebarSearch(p); - return chatUrl; - } - } - - await clearSidebarSearch(p); - await resetSidebarListToTop(p); - await waitForMaxChatListReady(p, 15000); - - let stagnantPages = 0; - for (let pageIndex = 0; pageIndex < 220; pageIndex++) { - chatUrl = await clickMatchingSidebarRowAndReadUrl(p, descriptor); - if (chatUrl) { - await clearSidebarSearch(p); - return chatUrl; - } - - const moved = await scrollSidebarForward(p); - if (!moved) break; - await p.waitForTimeout(250); - - const rows = await readVisibleSidebarChatRows(p); - stagnantPages = rows.length === 0 ? stagnantPages + 1 : 0; - if (stagnantPages >= 4) break; - } - - await clearSidebarSearch(p); - return null; -} - -async function collectSidebarChatUrls(p, limit = 600) { - await clearSidebarSearch(p); - await resetSidebarListToTop(p); - await waitForMaxChatListReady(p, 15000); - - const results = []; - const seen = new Set(); - let stagnantPages = 0; - - for (let pageIndex = 0; pageIndex < 220 && results.length < limit; pageIndex++) { - const rows = await readVisibleSidebarChatRows(p); - let addedOnPage = 0; - - for (const row of rows) { - if (results.length >= limit) break; - const key = `${row.title}|${row.avatarUrl || ""}|${row.text}`; - if (seen.has(key)) continue; - seen.add(key); - addedOnPage++; - - await p.mouse.click(row.x, row.y); - const ready = await waitForDomChatReady(p, row.title, 5000, false); - await p.waitForTimeout(150); - const chatUrl = await getCurrentChatUrl(p); - results.push({ - externalId: makeDomChatExternalId(row.title, row.text, "", results.length, row.avatarUrl), - title: row.title, - preview: row.preview, - timeText: row.timeText, - avatarUrl: row.avatarUrl || null, - chatUrl, - ready, - rowText: row.text - }); - } - - const moved = await scrollSidebarForward(p); - if (!moved) break; - await p.waitForTimeout(250); - - stagnantPages = addedOnPage === 0 ? stagnantPages + 1 : 0; - if (stagnantPages >= 4) break; - } - - return results; -} - -async function openDomChatByUrl(p, chatUrl) { - const targetUrl = normalizeMaxChatUrl(chatUrl); - if (!targetUrl) { - return false; - } - - const currentUrl = normalizeMaxChatUrl(p.url()); - if (currentUrl === targetUrl) { - return true; - } - - try { - await p.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); - await p.waitForTimeout(900); - return true; - } catch { - return false; - } -} - -async function scrollOpenChatToLatest(p) { - for (let attempt = 0; attempt < 2; attempt++) { - await p.evaluate(() => { - const root = document.querySelector("[class*='openedChat' i]") || - document.querySelector("main") || - document.body; - if (!(root instanceof HTMLElement)) return; - - const rootRect = root.getBoundingClientRect(); - const candidates = [ - root, - ...Array.from(root.querySelectorAll("[class*='scroll' i], [class*='contentOverflow' i], [class*='cropped' i], [class*='messages' i], [class*='messageList' i], [class*='list' i]")) - ].filter((element) => { - if (!(element instanceof HTMLElement)) return false; - const rect = element.getBoundingClientRect(); - return rect.width > 120 && - rect.height > 120 && - rect.right >= rootRect.left && - rect.left <= rootRect.right && - element.scrollHeight > element.clientHeight + 2; - }); - - for (const element of candidates) { - element.scrollTop = element.scrollHeight; - element.dispatchEvent(new Event("scroll", { bubbles: true })); - } - }).catch(() => {}); - await p.waitForTimeout(250); - } -} - -async function openDomChatByExternalId(p, externalChatId, options = {}) { - const descriptor = decodeDomChatDescriptor(externalChatId); - const title = descriptor?.title || externalChatId; - if (!title) { - return false; - } - - if (!options.preserveSidebarSearch) { - await clearSidebarSearch(p); - await resetSidebarListToTop(p); - await waitForMaxChatListReady(p, 15000); - } - - const point = await p.evaluate(async (requested) => { - const cleanText = (value) => String(value || "") - .replace(/\u00a0/g, " ") - .replace(/\s+/g, " ") - .trim(); - const absoluteUrl = (value) => { - const raw = String(value || "").trim(); - if (!raw || raw.startsWith("data:")) return ""; - try { - return new URL(raw, location.href).href; - } catch { - return raw; - } - }; - const fingerprintUrl = (value) => { - const raw = absoluteUrl(value); - if (!raw) return ""; - try { - const url = new URL(raw, location.href); - const mediaKey = url.searchParams.get("r"); - if (mediaKey) return `r:${mediaKey}`; - ["fn", "size", "width", "height"].forEach((key) => url.searchParams.delete(key)); - return url.href; - } catch { - return raw; - } - }; - const cssBackgroundUrl = (element) => { - const image = window.getComputedStyle(element).backgroundImage || ""; - const match = image.match(/url\(["']?(.+?)["']?\)/i); - return match ? absoluteUrl(match[1]) : ""; - }; - const extractAvatarUrl = (element) => { - const rowRect = element.getBoundingClientRect(); - const candidates = Array.from(element.querySelectorAll("img, image, [style*='background' i], [class*='avatar' i], [class*='photo' i]")); - for (const node of candidates) { - if (!(node instanceof Element)) continue; - const rect = node.getBoundingClientRect(); - if (rect.width < 24 || rect.height < 24 || rect.width > 96 || rect.height > 96) continue; - if (rect.left > rowRect.left + rowRect.width * 0.45) continue; - const source = node.currentSrc || - node.src || - node.href?.baseVal || - node.getAttribute("src") || - node.getAttribute("href") || - cssBackgroundUrl(node); - const url = absoluteUrl(source); - if (url && !/pattern|sprite|emoji|sticker|logo/i.test(url)) { - return url; - } - } - return ""; - }; - const normalize = (value) => cleanText(value).toLowerCase(); - const wanted = normalize(requested?.title); - const expectedAvatar = fingerprintUrl(requested?.avatarUrl); - const expectedHref = fingerprintUrl(requested?.href); - const comparableTitles = (left, right) => { - const minLength = Math.min(left.length, right.length); - const maxLength = Math.max(left.length, right.length); - return maxLength > 0 && minLength / maxLength >= 0.72; - }; - if (!wanted) { - return false; - } - - const opened = document.querySelector("[class*='openedChat' i]") || - document.querySelector("main") || - document.body; - const header = opened.querySelector("[class*='header' i]"); - const headerTitle = normalize( - header?.querySelector("h1,h2,h3,[class*='title' i],[class*='name' i]")?.innerText || - header?.querySelector("button[aria-label]")?.innerText); - const selectedTitle = normalize(document.querySelector("aside button[class*='selected' i] h3")?.innerText); - const hasVisibleMessages = Array.from(document.querySelectorAll("[class*='messageWrapper' i]")) - .some((node) => { - const rect = node.getBoundingClientRect?.(); - return rect && rect.width > 40 && rect.height > 20 && rect.right > window.innerWidth * 0.35; - }); - const currentTitles = [selectedTitle, headerTitle].filter(Boolean); - if (!expectedAvatar && !expectedHref && - hasVisibleMessages && - currentTitles.some((current) => { - if (current === wanted) return true; - return Math.min(current.length, wanted.length) >= 4 && - comparableTitles(current, wanted) && - (current.includes(wanted) || wanted.includes(current)); - })) { - return { opened: true }; - } - - const findMatch = () => { - const chatSelector = [ - "aside button.cell", - "aside button[class*='cell' i]", - "aside [role='presentation'] button", - "aside [class*='wrapper' i] button", - "aside [data-testid*='chat' i]", - "aside [data-testid*='dialog' i]", - "aside [data-testid*='conversation' i]", - "aside a[href*='chat' i]", - "aside a[href*='dialog' i]" - ].join(","); - const rows = Array.from(document.querySelectorAll(chatSelector)); - const candidates = rows.map((button) => { - const titleNode = button.querySelector("h3, [class*='title' i], [class*='name' i]"); - const title = normalize(titleNode?.innerText || titleNode?.textContent); - const text = normalize(button.innerText || button.textContent); - const titleMatches = title === wanted || - (Math.min(title.length, wanted.length) >= 4 && - comparableTitles(title, wanted) && - (title.includes(wanted) || wanted.includes(title))) || - text.startsWith(wanted); - if (!titleMatches) { - return null; - } - - const avatar = fingerprintUrl(extractAvatarUrl(button)); - const href = fingerprintUrl(button.getAttribute("href") || button.closest("a")?.getAttribute("href")); - let score = title === wanted ? 8 : 4; - if (expectedAvatar && avatar === expectedAvatar) score += 20; - if (expectedHref && href === expectedHref) score += 20; - return { button, avatar, href, score }; - }).filter(Boolean); - - const strict = candidates - .filter((candidate) => !expectedAvatar || candidate.avatar === expectedAvatar) - .filter((candidate) => !expectedHref || candidate.href === expectedHref) - .sort((a, b) => b.score - a.score); - return strict[0] || (candidates.length === 1 ? candidates[0] : null); - }; - - const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - let match = findMatch(); - if (!match) { - const scrollRoot = Array.from(document.querySelectorAll("aside, aside *")) - .filter((element) => element instanceof HTMLElement) - .filter((element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.display !== "none" && - style.visibility !== "hidden" && - rect.width > 180 && - rect.height > 160 && - element.scrollHeight > element.clientHeight + 80; - }) - .sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0] || null; - - if (scrollRoot) { - const maxTop = () => Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight); - scrollRoot.scrollTop = 0; - scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); - await delay(100); - - let previousTop = -1; - for (let page = 0; page < 100 && !match; page++) { - match = findMatch(); - if (match) break; - const bottom = maxTop(); - if (scrollRoot.scrollTop >= bottom - 4) break; - const nextTop = Math.min(bottom, scrollRoot.scrollTop + Math.max(220, Math.floor(scrollRoot.clientHeight * 0.8))); - if (nextTop === previousTop || nextTop === scrollRoot.scrollTop) break; - previousTop = scrollRoot.scrollTop; - scrollRoot.scrollTop = nextTop; - scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); - await delay(100); - } - } - } - - if (!match) { - return null; - } - - match.button.scrollIntoView({ block: "center", inline: "nearest" }); - const rect = match.button.getBoundingClientRect(); - return { - opened: false, - x: Math.round(rect.left + Math.min(rect.width - 8, Math.max(8, rect.width * 0.50))), - y: Math.round(rect.top + rect.height / 2) - }; - }, { - title, - avatarUrl: descriptor?.avatarUrl || "", - href: descriptor?.href || "" - }).catch(() => false); - - if (!point) { - return false; - } - - if (point.opened) { - return true; - } - - await p.mouse.click(point.x, point.y); - return true; -} - -async function ensureDomChatOpen(p, externalChatId, waitMs = 600, requireComposer = false, chatUrl = null) { - const descriptor = decodeDomChatDescriptor(externalChatId); - const title = descriptor?.title || externalChatId; - if (!title) { - return true; - } - - const directUrl = normalizeMaxChatUrl(chatUrl) || normalizeMaxChatUrl(descriptor?.href); - for (let attempt = 0; attempt < 2; attempt++) { - if (directUrl) { - const openedByUrl = await openDomChatByUrl(p, directUrl); - if (openedByUrl) { - const readyByUrl = await waitForDomChatReady( - p, - title, - Math.max(waitMs, requireComposer ? 8000 : 2500), - requireComposer); - if (readyByUrl) { - await clearSidebarSearch(p); - return true; - } - } - } - - let opened = await openDomChatByExternalId(p, externalChatId); - if (!opened) { - const searched = await searchSidebarChat(p, title); - if (searched) { - opened = await openDomChatByExternalId(p, externalChatId, { preserveSidebarSearch: true }); - } - } - if (!opened) { - await clearSidebarSearch(p); - return false; - } - - const ready = await waitForDomChatReady( - p, - title, - Math.max(waitMs, requireComposer ? 8000 : 2500), - requireComposer); - if (ready) { - await clearSidebarSearch(p); - return true; - } - - await clearSidebarSearch(p); - } - - return false; -} - -async function waitForDomChatReady(p, title, timeoutMs = 5000, requireComposer = false) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const state = await p.evaluate(({ requestedTitle, needsComposer }) => { - const cleanText = (value) => String(value || "") - .replace(/\u00a0/g, " ") - .replace(/\s+/g, " ") - .trim(); - const normalize = (value) => cleanText(value).toLowerCase(); - const wanted = normalize(requestedTitle); - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const normalizeClassName = (value) => typeof value === "string" ? value : String(value?.baseVal || ""); - const opened = document.querySelector("[class*='openedChat' i]") || - document.querySelector("main") || - document.body; - const rightPaneLeft = window.innerWidth * 0.32; - const headerCandidates = Array.from(opened.querySelectorAll("[class*='header' i], header")) - .filter((node) => isVisible(node)) - .filter((node) => node.getBoundingClientRect().left > rightPaneLeft); - const header = headerCandidates[0] || null; - const headerTitle = cleanText( - header?.querySelector("h1,h2,h3,[class*='title' i],[class*='name' i]")?.innerText || - header?.querySelector("button[aria-label]")?.innerText || - header?.innerText, - 160); - const selectedTitle = cleanText(document.querySelector("aside button[class*='selected' i] h3")?.innerText, 160); - const composerSelectors = [ - "[data-testid='composer'] [role='textbox']", - "[data-testid='composer'] [contenteditable='true']", - "[class*='composer' i] [role='textbox']", - "[class*='composer' i] [contenteditable='true']", - "[class*='composer' i] [class*='contenteditable' i]", - "[role='textbox'][class*='contenteditable' i]", - "textarea" - ]; - const hasComposer = composerSelectors - .flatMap((selector) => Array.from(document.querySelectorAll(selector))) - .some((node) => { - if (!isVisible(node)) return false; - const rect = node.getBoundingClientRect(); - const text = normalize([ - node.getAttribute("aria-label"), - node.getAttribute("placeholder"), - node.getAttribute("title"), - node.getAttribute("data-testid"), - normalizeClassName(node.className), - node.id, - node.getAttribute("name") - ].join(" ")); - const searchLike = /search|\u043f\u043e\u0438\u0441\u043a/.test(text) || - (rect.left < window.innerWidth * 0.42 && rect.top < window.innerHeight * 0.32); - return !searchLike && - rect.left > rightPaneLeft && - rect.top > window.innerHeight * 0.45; - }); - const current = normalize(headerTitle || (hasComposer ? selectedTitle : "")); - const comparableTitles = (left, right) => { - const minLength = Math.min(left.length, right.length); - const maxLength = Math.max(left.length, right.length); - return maxLength > 0 && minLength / maxLength >= 0.72; - }; - const titleMatches = !wanted || - (current && (current === wanted || - (Math.min(current.length, wanted.length) >= 4 && - comparableTitles(current, wanted) && - (current.includes(wanted) || wanted.includes(current))))); - const ready = Boolean(titleMatches && header && (!needsComposer || hasComposer)); - return { ready, headerTitle, selectedTitle, hasHeader: Boolean(header), hasComposer }; - }, { requestedTitle: title, needsComposer: requireComposer }).catch(() => null); - - if (state?.ready) { - return true; - } - - await p.waitForTimeout(250); - } - - return false; -} - -async function sendTextAndConfirm(p, externalChatId, text) { - const wanted = cleanComparableText(text, 1000); - if (!wanted) { - return null; - } - - const initialOutgoingMessages = await getOutgoingMessages(p, externalChatId); - const initialOutgoingIds = new Set(initialOutgoingMessages.map((message) => message.externalId).filter(Boolean)); - const initialMatches = initialOutgoingMessages - .filter((message) => messageTextMatches(message.text, wanted)) - .length; - const attempts = [ - { name: "send button", action: () => clickSendButton(p) }, - { name: "composer fallback button", action: () => clickComposerRightmostButton(p) }, - { name: "Enter", action: async () => { await p.keyboard.press("Enter"); return true; } }, - { name: "Control+Enter", action: async () => { await p.keyboard.press("Control+Enter"); return true; } } - ]; - - for (const attempt of attempts) { - if (!(await fillComposerText(p, text, true))) { - console.warn(`[send/text] composer fill failed; chat=${externalChatId || "current"} textLength=${String(text || "").length}`); - return null; - } - - const composerState = await readComposerState(p); - if (!composerStateMatchesText(composerState, text)) { - console.warn(`[send/text] composer mismatch after fill; attempt=${attempt.name} composerLength=${composerState?.text?.length ?? -1} richCount=${composerState?.richContentCount ?? -1} wantedLength=${wanted.length}`); - continue; - } - - const triggered = await attempt.action().catch(() => false); - if (!triggered) { - console.warn(`[send/text] send trigger was not found; attempt=${attempt.name}`); - continue; - } - - await scrollOpenChatToBottom(p); - const externalId = await findSentOutgoingExternalId( - p, - externalChatId, - text, - initialMatches, - initialOutgoingIds, - 10000); - if (externalId) { - console.info(`[send/text] confirmed outgoing message; attempt=${attempt.name} externalId=${externalId}`); - return externalId; - } - - const currentComposerState = await readComposerState(p); - if (composerStateIsEmpty(currentComposerState)) { - console.warn(`[send/text] MAX cleared composer but no outgoing message appeared; attempt=${attempt.name}`); - return null; - } - } - - return null; -} - -async function sendAttachmentAndConfirm(p, externalChatId, path, caption) { - const initialOutgoingMessages = await getOutgoingMessages(p, externalChatId); - const initialOutgoingIds = new Set(initialOutgoingMessages.map((message) => message.externalId).filter(Boolean)); - const wantedCaption = cleanComparableText(caption, 1000); - const initialMatches = wantedCaption - ? initialOutgoingMessages.filter((message) => messageTextMatches(message.text, wantedCaption)).length - : 0; - - console.info(`[send/attachment] starting upload; chat=${externalChatId || "current"} captionLength=${String(caption || "").trim().length} path=${path}`); - const triggered = await uploadAttachmentFile(p, path, caption); - if (!triggered) { - console.warn(`[send/attachment] upload/send trigger failed; chat=${externalChatId || "current"} path=${path}`); - return null; - } - - await scrollOpenChatToBottom(p); - const externalId = await findSentOutgoingExternalId( - p, - externalChatId, - caption || "", - initialMatches, - initialOutgoingIds, - 20000); - if (externalId) { - console.info(`[send/attachment] confirmed outgoing attachment; externalId=${externalId}`); - return externalId; - } - - console.warn(`[send/attachment] MAX did not expose a new outgoing attachment; chat=${externalChatId || "current"} path=${path}`); - return null; -} - -async function fillComposerText(p, text, clearFirst) { - if (clearFirst && !(await clearComposerInput(p))) { - console.warn("[send/text] composer did not report empty after clear; continuing with input fallback"); - } - - if (await fillComposerViaClipboard(p, text, false)) { - return true; - } - - if (clearFirst) { - await clearComposerInput(p); - } - if (await fillComposerViaKeyboard(p, text, false)) { - return true; - } - - if (clearFirst) { - await clearComposerInput(p); - } - if (await insertComposerTextViaDom(p, text, false)) { - await p.waitForTimeout(300); - if (composerStateMatchesText(await readComposerState(p), text)) { - return true; - } - } - - if (clearFirst && await fillComposerViaLocator(p, text)) { - return true; - } - - for (let attempt = 0; attempt < 2; attempt++) { - if (!(await focusComposer(p))) { - break; - } - - if (clearFirst) { - await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); - await p.keyboard.press("Backspace"); - } - await p.keyboard.insertText(text); - await p.waitForTimeout(300); - - const state = await readComposerState(p); - if (composerStateMatchesText(state, text)) { - return true; - } - } - - const selectors = [ - "[data-testid='composer'] [role='textbox']", - "[data-testid='composer'] [contenteditable='true']", - "[class*='composer' i] [role='textbox']", - "[class*='composer' i] [contenteditable='true']", - "[class*='composer' i] [class*='contenteditable' i]" - ]; - - for (const selector of selectors) { - try { - const composer = p.locator(selector).last(); - if ((await composer.count()) === 0) { - continue; - } - - await composer.click({ timeout: 3000 }); - if (clearFirst) { - await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); - await p.keyboard.press("Backspace"); - } - await p.keyboard.insertText(text); - await p.waitForTimeout(250); - const state = await readComposerState(p); - if (composerStateMatchesText(state, text)) { - return true; - } - } catch { - // Try the next composer shape. - } - } - - return false; -} - -async function clearComposerInput(p) { - if (!(await focusComposer(p))) { - return false; - } - - for (let attempt = 0; attempt < 2; attempt++) { - await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); - await p.keyboard.press("Backspace"); - await p.waitForTimeout(120); - if (composerStateIsEmpty(await readComposerState(p))) { - return true; - } - } - - await insertComposerTextViaDom(p, "", true); - await p.waitForTimeout(150); - return composerStateIsEmpty(await readComposerState(p)); -} - -async function fillComposerViaClipboard(p, text, clearFirst) { - if (!(await focusComposer(p))) { - return false; - } - - if (clearFirst) { - await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); - await p.keyboard.press("Backspace"); - } - - const wroteClipboard = await p.evaluate(async (value) => { - try { - await navigator.clipboard.writeText(value); - return true; - } catch { - return false; - } - }, text).catch(() => false); - - if (wroteClipboard) { - await p.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V"); - await p.waitForTimeout(300); - if (composerStateMatchesText(await readComposerState(p), text)) { - return true; - } - } - - return false; -} - -async function fillComposerViaKeyboard(p, text, clearFirst) { - if (!(await focusComposer(p))) { - return false; - } - - if (clearFirst) { - await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); - await p.keyboard.press("Backspace"); - await p.waitForTimeout(80); - } - - await p.keyboard.insertText(text); - await p.waitForTimeout(300); - return composerStateMatchesText(await readComposerState(p), text); -} - -async function insertComposerTextViaDom(p, text, clearFirst = false) { - return await p.evaluate(({ value, clear }) => { - const cleanText = (textValue) => String(textValue || "") - .replace(/\u00a0/g, " ") - .replace(/[\u200b\u200c\u200d\ufeff]/g, "") - .replace(/\s+/g, " ") - .trim(); - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const isSearchLike = (element, rect) => { - const textValue = cleanText([ - element.getAttribute("aria-label"), - element.getAttribute("placeholder"), - element.getAttribute("title"), - element.getAttribute("data-testid"), - element.className, - element.id, - element.getAttribute("name") - ].join(" ")).toLowerCase(); - return /search|\u043f\u043e\u0438\u0441\u043a/.test(textValue) || - (rect.left < window.innerWidth * 0.42 && rect.top < window.innerHeight * 0.32); - }; - const selectors = [ - "[data-testid='composer'] [role='textbox']", - "[data-testid='composer'] [contenteditable='true']", - "[class*='composer' i] [role='textbox']", - "[class*='composer' i] [contenteditable='true']", - "[class*='composer' i] [class*='contenteditable' i]", - "[role='textbox'][class*='contenteditable' i]", - "[role='textbox']", - "[contenteditable='true']", - "textarea" - ]; - const candidates = selectors - .flatMap((selector) => Array.from(document.querySelectorAll(selector))) - .filter((element) => isVisible(element)) - .filter((element) => { - const rect = element.getBoundingClientRect(); - return !isSearchLike(element, rect) && - rect.left > window.innerWidth * 0.32 && - rect.top > window.innerHeight * 0.50; - }) - .sort((a, b) => { - const ar = a.getBoundingClientRect(); - const br = b.getBoundingClientRect(); - return br.bottom - ar.bottom || (br.width * br.height) - (ar.width * ar.height); - }); - const composer = candidates[0]; - if (!(composer instanceof HTMLElement)) { - return false; - } - - composer.focus({ preventScroll: true }); - if (clear) { - if ("value" in composer) { - composer.value = ""; - } else { - composer.replaceChildren(); - } - composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null })); - } - - if (!value) { - composer.dispatchEvent(new Event("change", { bubbles: true })); - return true; - } - - const beforeInput = new InputEvent("beforeinput", { - bubbles: true, - cancelable: true, - inputType: "insertText", - data: value - }); - composer.dispatchEvent(beforeInput); - if ("value" in composer) { - composer.value = `${composer.value || ""}${value}`; - } else if (!document.execCommand("insertText", false, value)) { - composer.textContent = `${composer.textContent || ""}${value}`; - } - composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value })); - composer.dispatchEvent(new Event("change", { bubbles: true })); - return true; - }, { value: text, clear: clearFirst }).catch(() => false); -} - -async function fillComposerViaLocator(p, text) { - const selectors = [ - "[data-testid='composer'] [role='textbox']", - "[data-testid='composer'] [contenteditable='true']", - "[class*='composer' i] [role='textbox']", - "[class*='composer' i] [contenteditable='true']", - "[class*='composer' i] [class*='contenteditable' i]", - "[role='textbox'][class*='contenteditable' i]" - ]; - - for (const selector of selectors) { - try { - const composer = p.locator(selector).last(); - if ((await composer.count()) === 0) { - continue; - } - - await composer.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => {}); - await composer.click({ timeout: 3000 }); - await composer.fill("", { timeout: 3000 }); - await composer.fill(text, { timeout: 3000 }); - await p.waitForTimeout(200); - if (composerStateMatchesText(await readComposerState(p), text)) { - return true; - } - } catch { - // Try the next composer shape. - } - } - - return false; -} - -async function focusComposer(p) { - const point = await p.evaluate(() => { - const cleanText = (value) => String(value || "") - .replace(/\u00a0/g, " ") - .replace(/\s+/g, " ") - .trim() - .toLowerCase(); - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const isSearchLike = (element, rect) => { - const text = cleanText([ - element.getAttribute("aria-label"), - element.getAttribute("placeholder"), - element.getAttribute("title"), - element.getAttribute("data-testid"), - element.className, - element.id, - element.getAttribute("name") - ].join(" ")); - return /search|\u043f\u043e\u0438\u0441\u043a/.test(text) || - (rect.left < window.innerWidth * 0.42 && rect.top < window.innerHeight * 0.32); - }; - const selectors = [ - "[data-testid='composer'] [role='textbox']", - "[data-testid='composer'] [contenteditable='true']", - "[class*='composer' i] [role='textbox']", - "[class*='composer' i] [contenteditable='true']", - "[class*='composer' i] [class*='contenteditable' i]", - "[role='textbox'][class*='contenteditable' i]", - "[contenteditable='true']", - "textarea" - ]; - const candidates = selectors - .flatMap((selector) => Array.from(document.querySelectorAll(selector))) - .filter(isVisible) - .filter((element) => { - const rect = element.getBoundingClientRect(); - return !isSearchLike(element, rect) && - rect.left > window.innerWidth * 0.32 && - rect.top > window.innerHeight * 0.50; - }) - .map((element) => { - const rect = element.getBoundingClientRect(); - return { - element, - x: Math.round(rect.left + rect.width / 2), - y: Math.round(rect.top + rect.height / 2), - bottom: Math.round(rect.bottom), - area: Math.round(rect.width * rect.height) - }; - }) - .sort((a, b) => b.bottom - a.bottom || b.area - a.area); - const candidate = candidates[0]; - if (candidate?.element instanceof HTMLElement) { - candidate.element.scrollIntoView({ block: "center", inline: "nearest" }); - candidate.element.focus({ preventScroll: true }); - const rect = candidate.element.getBoundingClientRect(); - return { - x: Math.round(rect.left + rect.width / 2), - y: Math.round(rect.top + rect.height / 2) - }; - } - - return { - x: Math.round(window.innerWidth * 0.55), - y: Math.round(window.innerHeight - 40) - }; - }).catch(() => null); - - if (!point) { - return false; - } - - await p.mouse.click(point.x, point.y); - await p.waitForTimeout(150); - return true; -} - -async function waitForComposerToClear(p, timeoutMs = 4000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (composerStateIsEmpty(await readComposerState(p))) { - return true; - } - - await p.waitForTimeout(250); - } - - return false; -} - -async function readComposerText(p) { - const state = await readComposerState(p); - return state?.text ?? null; -} - -async function readComposerState(p) { - return await p.evaluate(() => { - const cleanText = (value) => String(value || "") - .replace(/\u00a0/g, " ") - .replace(/[\u200b\u200c\u200d\ufeff]/g, "") - .replace(/\s+/g, " ") - .trim(); - const isVisible = (element) => { - if (!(element instanceof Element)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const isSearchLike = (element, rect) => { - const text = cleanText([ - element.getAttribute("aria-label"), - element.getAttribute("placeholder"), - element.getAttribute("title"), - element.getAttribute("data-testid"), - element.className, - element.id, - element.getAttribute("name") - ].join(" ")).toLowerCase(); - return /search|\u043f\u043e\u0438\u0441\u043a/.test(text) || - (rect.left < window.innerWidth * 0.42 && rect.top < window.innerHeight * 0.32); - }; - const selectors = [ - "[data-testid='composer'] [role='textbox']", - "[data-testid='composer'] [contenteditable='true']", - "[class*='composer' i] [role='textbox']", - "[class*='composer' i] [contenteditable='true']", - "[class*='composer' i] [class*='contenteditable' i]", - "[role='textbox']", - "[contenteditable='true']", - "textarea" - ]; - const candidates = selectors - .flatMap((selector) => Array.from(document.querySelectorAll(selector))) - .filter((element) => element instanceof HTMLElement && isVisible(element)) - .filter((element) => { - const rect = element.getBoundingClientRect(); - return !isSearchLike(element, rect) && - rect.left > window.innerWidth * 0.32 && - rect.top > window.innerHeight * 0.50; - }) - .sort((a, b) => { - const ar = a.getBoundingClientRect(); - const br = b.getBoundingClientRect(); - return br.bottom - ar.bottom || (br.width * br.height) - (ar.width * ar.height); - }); - const composer = candidates[0]; - if (!composer) { - return null; - } - - const value = "value" in composer ? composer.value : ""; - const text = cleanText(value || composer.innerText || composer.textContent); - const richContentCount = Array.from(composer.querySelectorAll([ - "img", - "canvas", - "svg", - "[class*='emoji' i]", - "[class*='sticker' i]", - "[class*='lottie' i]", - "[class*='emoticon' i]", - "[data-testid*='emoji' i]", - "[data-testid*='sticker' i]", - "[data-testid*='lottie' i]" - ].join(","))) - .filter((element) => isVisible(element)) - .filter((element) => !/placeholder/i.test(String(element.className || ""))) - .length; - return { text, richContentCount }; - }).catch(() => null); -} - -function composerStateIsEmpty(state) { - return Boolean(state && !state.text && (state.richContentCount || 0) === 0); -} - -function composerStateMatchesText(state, wantedText) { - if (!state) { - return false; - } - - if (messageTextMatches(state.text, wantedText)) { - return true; - } - - if ((state.richContentCount || 0) === 0 || !containsEmojiSyntax(wantedText)) { - return false; - } - - if (isEmojiOnlyText(wantedText)) { - return true; - } - - const composerText = stripEmojiForComparison(state.text); - const wanted = stripEmojiForComparison(wantedText); - return Boolean(composerText && wanted && ( - composerText === wanted || - composerText.endsWith(wanted) || - wanted.endsWith(composerText) - )); -} - -function containsEmojiSyntax(value) { - return /[\p{Extended_Pictographic}\u200d\ufe0f\ufe0e]/u.test(String(value || "")); -} - -function isEmojiOnlyText(value) { - const text = cleanComparableText(value, 1000); - return Boolean(text && containsEmojiSyntax(text) && !stripEmojiForComparison(text)); -} - -function stripEmojiForComparison(value) { - return cleanComparableText(value, 1000) - .replace(/[\p{Extended_Pictographic}\u200d\ufe0f\ufe0e]/gu, "") - .replace(/\s+/g, " ") - .trim() - .toLowerCase(); -} - -function messageTextMatches(value, wantedText) { - const text = cleanComparableText(value, 1000).toLowerCase(); - const wanted = cleanComparableText(wantedText, 1000).toLowerCase(); - return Boolean(text && wanted && ( - text === wanted || - text.endsWith(`: ${wanted}`) || - text.endsWith(wanted) || - text.includes(wanted) - )); -} - -async function getOutgoingMessages(p, externalChatId) { - const history = await extractOpenChatHistory(p, externalChatId).catch(() => null); - return (history?.messages || []).filter((message) => message.isOutgoing); -} - -async function getOutgoingTextMatches(p, externalChatId, text) { - const wanted = cleanComparableText(text, 1000); - if (!wanted) { - return []; - } - - return (await getOutgoingMessages(p, externalChatId)) - .filter((message) => messageTextMatches(message.text, wanted)); -} - -async function countOutgoingTextMatches(p, externalChatId, text) { - return (await getOutgoingTextMatches(p, externalChatId, text)).length; -} - -async function findSentOutgoingExternalId( - p, - externalChatId, - text, - previousMatchCount = 0, - initialOutgoingIds = new Set(), - timeoutMs = 15000) { - const knownIds = initialOutgoingIds instanceof Set - ? initialOutgoingIds - : new Set(Array.from(initialOutgoingIds || []).filter(Boolean)); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - await p.waitForTimeout(500); - const outgoingMessages = await getOutgoingMessages(p, externalChatId); - const matches = outgoingMessages.filter((message) => messageTextMatches(message.text, text)); - if (matches.length > previousMatchCount) { - const latest = matches[matches.length - 1]; - if (latest?.externalId) { - return latest.externalId; - } - } - - const newOutgoing = outgoingMessages.filter((message) => - message.externalId && - !knownIds.has(message.externalId)); - if (newOutgoing.length > 0) { - return newOutgoing[newOutgoing.length - 1].externalId; - } - } - - return null; -} - -async function uploadAttachmentFile(p, path, caption) { - try { - const fileStat = await fs.stat(path); - if (!fileStat.isFile()) { - console.warn(`[send/attachment] file is not a regular file; path=${path}`); - return false; - } - } catch { - console.warn(`[send/attachment] file does not exist or is not readable; path=${path}`); - return false; - } - - let attached = await attachFileViaUploadButton(p, path); - - if (!attached) { - try { - const input = p.locator("input[type='file']").last(); - if ((await input.count()) > 0) { - await input.setInputFiles(path); - attached = true; - } - } catch { - attached = false; - } - } - - if (!attached) { - console.warn(`[send/attachment] no usable upload control was found; path=${path}`); - return false; - } - - await p.waitForTimeout(2000); - const previewReady = await waitForAttachmentPreview(p, 10000); - console.info(`[send/attachment] file selected; previewReady=${previewReady} url=${p.url()}`); - if (!previewReady) { - return false; - } - - if (caption.trim()) { - await fillComposerText(p, caption.trim(), false); - await p.waitForTimeout(300); - } - - const sent = await clickSendButton(p) || await clickComposerRightmostButton(p); - if (!sent) { - console.warn(`[send/attachment] send button was not found after file selection; path=${path}`); - return false; - } - - await p.waitForTimeout(1500); - return true; -} - -async function attachFileViaUploadButton(p, filePath) { - const point = await p.evaluate(() => { - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - - const buttons = Array.from(document.querySelectorAll("button, [role='button']")) - .filter((button) => !button.disabled && isVisible(button)); - const candidates = buttons - .map((button) => { - const rect = button.getBoundingClientRect(); - const text = [ - button.getAttribute("aria-label"), - button.getAttribute("title"), - button.getAttribute("data-testid"), - button.getAttribute("name"), - button.className, - button.innerText, - button.textContent - ].join(" ").trim().toLowerCase(); - const explicit = /attach|attachment|upload|file|paperclip|\u043f\u0440\u0438\u043a\u0440\u0435\u043f|\u0437\u0430\u0433\u0440\u0443\u0437|\u0444\u0430\u0439\u043b|\u0441\u043a\u0440\u0435\u043f/.test(text); - const composerLike = rect.left > window.innerWidth * 0.32 && rect.top > window.innerHeight * 0.55; - return { button, rect, explicit, composerLike }; - }) - .filter((candidate) => candidate.explicit || candidate.composerLike) - .sort((a, b) => - Number(b.explicit) - Number(a.explicit) || - Number(b.composerLike) - Number(a.composerLike) || - a.rect.left - b.rect.left || - b.rect.top - a.rect.top); - const button = candidates[0]?.button || null; - if (!button) { - return null; - } - - const rect = button.getBoundingClientRect(); - return { - x: Math.round(rect.left + rect.width / 2), - y: Math.round(rect.top + rect.height / 2) - }; - }).catch(() => null); - - if (!point) { - return false; - } - - try { - const fileChooserPromise = p.waitForEvent("filechooser", { timeout: 800 }).catch(() => null); - await p.mouse.click(point.x, point.y); - const fileChooser = await fileChooserPromise; - if (fileChooser) { - await fileChooser.setFiles(filePath); - return true; - } - - return await attachFileViaMenuItem(p, filePath); - } catch { - return false; - } -} - -async function attachFileViaMenuItem(p, filePath) { - const preferMedia = /\.(jpe?g|png|webp|gif|heic|mp4|mov|webm)$/i.test(filePath); - const deadline = Date.now() + 3500; - let point = null; - while (Date.now() < deadline && !point) { - point = await p.evaluate(({ preferMedia }) => { - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const cleanText = (value) => String(value || "") - .replace(/\s+/g, " ") - .trim() - .toLowerCase(); - const menuItems = Array.from(document.querySelectorAll("[role='menuitem'], button[class*='actionsMenuItem' i]")) - .filter((element) => !element.disabled && isVisible(element)) - .filter((element) => !element.closest?.("[data-testid='composer'], [class*='composer' i]")) - .map((element) => { - const rect = element.getBoundingClientRect(); - const text = cleanText([ - element.getAttribute("aria-label"), - element.getAttribute("title"), - element.getAttribute("data-testid"), - element.className, - element.innerText, - element.textContent - ].join(" ")); - const media = /photo|video|media|\u0444\u043e\u0442\u043e|\u0432\u0438\u0434\u0435\u043e/.test(text); - const file = /file|\u0444\u0430\u0439\u043b/.test(text); - return { element, rect, text, media, file }; - }) - .filter((item) => item.media || item.file) - .sort((a, b) => { - const aPreferred = preferMedia ? a.media : a.file; - const bPreferred = preferMedia ? b.media : b.file; - return Number(bPreferred) - Number(aPreferred) || - a.rect.top - b.rect.top || - a.rect.left - b.rect.left; - }); - const item = menuItems[0]?.element || null; - if (!item) { - return null; - } - - const rect = item.getBoundingClientRect(); - return { - x: Math.round(rect.left + rect.width / 2), - y: Math.round(rect.top + rect.height / 2) - }; - }, { preferMedia }).catch(() => null); - - if (!point) { - await p.waitForTimeout(100); - } - } - - if (!point) { - return false; - } - - try { - const fileChooserPromise = p.waitForEvent("filechooser", { timeout: 5000 }).catch(() => null); - await p.mouse.click(point.x, point.y); - const fileChooser = await fileChooserPromise; - if (!fileChooser) { - return false; - } - - await fileChooser.setFiles(filePath); - return true; - } catch { - return false; - } -} - -async function waitForAttachmentPreview(p, timeoutMs = 8000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const ready = await p.evaluate(() => { - const isVisible = (element) => { - if (!(element instanceof Element)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const bottomArea = (element) => { - const rect = element.getBoundingClientRect(); - return rect.left > window.innerWidth * 0.30 && rect.top > window.innerHeight * 0.45; - }; - const isPendingUploadSurface = (element) => { - if (element.closest?.("aside, [class*='messageWrapper' i], [class*='bubble' i], [class*='message--' i], [role='listitem']")) { - return false; - } - - const surface = element.closest?.([ - "[data-testid='composer']", - "[class*='composer' i]", - "[role='dialog']", - "[class*='modal' i]", - "[class*='preview' i]", - "[class*='attach' i]", - "[class*='upload' i]", - "[class*='file' i]", - "[class*='media' i]" - ].join(",")); - if (surface) { - return true; - } - - const rect = element.getBoundingClientRect(); - return rect.left > window.innerWidth * 0.38 && - rect.top > window.innerHeight * 0.55 && - rect.bottom > window.innerHeight * 0.70; - }; - const previewSelectors = [ - "[class*='preview' i]", - "[class*='attach' i]", - "[class*='upload' i]", - "[class*='file' i]", - "[class*='media' i]", - "img", - "video" - ]; - const hasPreview = previewSelectors - .flatMap((selector) => Array.from(document.querySelectorAll(selector))) - .some((element) => isVisible(element) && bottomArea(element) && isPendingUploadSurface(element)); - const hasFileName = Array.from(document.querySelectorAll("div, span, p")) - .some((element) => { - if (!isVisible(element) || !bottomArea(element) || !isPendingUploadSurface(element)) { - return false; - } - - const text = String(element.innerText || element.textContent || "").trim(); - return /\.(jpe?g|png|webp|gif|heic|mp4|mov|webm|mp3|ogg|m4a|wav|pdf|docx?|xlsx?|zip|rar|7z)\b/i.test(text); - }); - return hasPreview || hasFileName; - }).catch(() => false); - - if (ready) { - return true; - } - - await p.waitForTimeout(250); - } - - console.warn("[send/attachment] attachment preview was not detected before sending"); - return false; -} - -async function clickSendButton(p) { - const point = await p.evaluate(() => { - const isVisible = (element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const buttons = Array.from(document.querySelectorAll("button, [role='button']")) - .filter((button) => !button.disabled && isVisible(button)); - const candidates = buttons - .map((button) => { - const rect = button.getBoundingClientRect(); - const text = [ - button.getAttribute("aria-label"), - button.getAttribute("title"), - button.getAttribute("data-testid"), - button.className, - button.innerText, - button.textContent - ].join(" ").trim().toLowerCase(); - const exact = /send message|\u043e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435/.test(text); - const generic = /send|submit|\u043e\u0442\u043f\u0440\u0430\u0432/.test(text); - return { - button, - rect, - exact, - generic, - bottomRight: rect.left > window.innerWidth * 0.32 && rect.top > window.innerHeight * 0.55 - }; - }) - .filter((candidate) => candidate.generic); - const match = candidates - .sort((a, b) => - Number(b.exact) - Number(a.exact) || - Number(b.bottomRight) - Number(a.bottomRight) || - b.rect.left - a.rect.left || - b.rect.top - a.rect.top)[0]?.button || null; - - if (!match) { - return null; - } - - const rect = match.getBoundingClientRect(); - return { - x: Math.round(rect.left + rect.width / 2), - y: Math.round(rect.top + rect.height / 2) - }; - }).catch(() => false); - - if (!point) { - return false; - } - - await p.mouse.click(point.x, point.y); - return true; -} - -async function clickComposerRightmostButton(p) { - const point = await p.evaluate(() => { - const isVisible = (element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const isComposerButton = (button) => { - const rect = button.getBoundingClientRect(); - if (rect.left < window.innerWidth * 0.32 || rect.top < window.innerHeight * 0.55) { - return false; - } - const text = [ - button.getAttribute("aria-label"), - button.getAttribute("title"), - button.getAttribute("data-testid"), - button.className, - button.innerText, - button.textContent - ].join(" ").trim().toLowerCase(); - return !/sticker|emoji|attach|upload|\u0441\u0442\u0438\u043a\u0435\u0440|\u044d\u043c\u043e\u0434\u0437\u0438|\u0444\u0430\u0439\u043b|\u0437\u0430\u0433\u0440\u0443\u0437/.test(text); - }; - - const buttons = Array.from(document.querySelectorAll("button, [role='button']")) - .filter((button) => !button.disabled && isVisible(button) && isComposerButton(button)) - .sort((a, b) => b.getBoundingClientRect().left - a.getBoundingClientRect().left); - const button = buttons[0]; - if (!button) { - return null; - } - - const rect = button.getBoundingClientRect(); - return { - x: Math.round(rect.left + rect.width / 2), - y: Math.round(rect.top + rect.height / 2) - }; - }).catch(() => false); - - if (!point) { - return false; - } - - await p.mouse.click(point.x, point.y); - return true; -} - -async function scrollOpenChatToBottom(p) { - await p.evaluate(() => { - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const root = Array.from(document.querySelectorAll("main *, [role='main'] *, [class*='scroll' i]")) - .filter((element) => element instanceof HTMLElement && isVisible(element)) - .filter((element) => { - const rect = element.getBoundingClientRect(); - return rect.left > window.innerWidth * 0.32 && - rect.top < window.innerHeight * 0.82 && - element.scrollHeight > element.clientHeight + 40; - }) - .sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0]; - if (root instanceof HTMLElement) { - root.scrollTop = root.scrollHeight; - root.dispatchEvent(new Event("scroll", { bubbles: true })); - } - }).catch(() => {}); - await p.waitForTimeout(120); -} - -async function editMessageInMax(p, externalChatId, externalMessageId, currentText, text) { - const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText); - if (!target) { - return { success: false, error: "MAX message was not found on the visible chat history." }; - } - - if (!(await openMessageMenu(p, target.rect))) { - return { success: false, error: "MAX message menu was not opened." }; - } - - if (!(await clickActionByText(p, ["\u0440\u0435\u0434\u0430\u043a\u0442", "\u0438\u0437\u043c\u0435\u043d", "edit"]))) { - return { success: false, error: "MAX edit action was not found in the message menu." }; - } - - await p.waitForTimeout(600); - if (!(await fillComposerText(p, text, true))) { - return { success: false, error: "MAX edit composer was not found." }; - } - - if (!(await clickSendButton(p))) { - await p.keyboard.press("Enter"); - } - await p.waitForTimeout(1000); - return { success: true, error: null }; -} - -async function deleteMessageInMax(p, externalChatId, externalMessageId, currentText) { - const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText); - if (!target) { - return { success: false, error: "MAX message was not found on the visible chat history." }; - } - - if (!(await openMessageMenu(p, target.rect))) { - return { success: false, error: "MAX message menu was not opened." }; - } - - if (!(await clickActionByText(p, ["\u0443\u0434\u0430\u043b", "delete", "remove"]))) { - return { success: false, error: "MAX delete action was not found in the message menu." }; - } - - await p.waitForTimeout(500); - await clickActionByText(p, ["\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "\u0434\u0430", "delete", "yes", "ok"]); - await p.waitForTimeout(1000); - return { success: true, error: null }; -} - -async function clearChatHistoryInMax(p, externalChatId, chatUrl) { - return await applyChatMenuActionInMax( - p, - externalChatId, - chatUrl, - ["\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u0438\u0441\u0442\u043e\u0440", "\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u0447\u0430\u0442", "clear history", "clear chat"], - ["\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c", "\u0434\u0430", "clear", "yes", "ok"], - "clear history"); -} - -async function applyChatMenuActionInMax(p, externalChatId, chatUrl, actionLabels, confirmLabels, actionName) { - const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, actionLabels, actionName === "delete chat"); - if (!menuOpened) { - if (actionName === "delete chat" && !(await isSidebarChatPresent(p, externalChatId, chatUrl))) { - return { success: true, error: null }; - } - - return { success: false, error: `MAX ${actionName} menu was not opened.` }; - } - - const actionClick = actionName === "delete chat" - ? await clickActionByTextWithMouseRetry(p, actionLabels, 1500) - : { clicked: await clickActionByTextWithRetry(p, actionLabels, 1500), text: null }; - if (actionName === "delete chat") { - console.info(`[chat/delete] action clicked=${actionClick.clicked} text=${actionClick.text || ""}`); - } - - if (!actionClick.clicked) { - const menuTexts = await readVisibleActionTexts(p); - await p.keyboard.press("Escape").catch(() => {}); - await clearSidebarSearch(p); - if (actionName === "delete chat") { - console.info(`[chat/delete] skipped because MAX menu has no delete action. visible=${menuTexts.slice(0, 12).join(" | ")}`); - return { success: true, error: null }; - } - - return { success: false, error: `MAX ${actionName} action was not found in the chat menu.` }; - } - - if (actionName === "delete chat") { - let confirmed = false; - await p.waitForTimeout(700); - for (let attempt = 0; attempt < 5; attempt++) { - const beforeTexts = await readVisibleActionTexts(p); - const click = await clickDestructiveConfirmationByTextWithRetry( - p, - confirmLabels, - attempt === 0 ? 3500 : 1800); - console.info(`[chat/delete] confirm attempt=${attempt + 1} clicked=${click.clicked} text=${click.text || ""} visible=${beforeTexts.slice(0, 14).join(" | ")}`); - if (!click.clicked) { - if (attempt === 0) { - await p.keyboard.press("Escape").catch(() => {}); - await clearSidebarSearch(p); - return { - success: false, - error: `MAX ${actionName} confirmation was not found. Visible actions: ${beforeTexts.slice(0, 12).join(" | ")}` - }; - } - break; - } - confirmed = true; - await p.waitForTimeout(900); - } - await p.waitForTimeout(1200); - await clearSidebarSearch(p); - const removed = await waitForSidebarChatRemoval(p, externalChatId, chatUrl, 10000); - if (!removed) { - return { success: false, error: "MAX delete chat action did not remove the chat from the sidebar." }; - } - - return { success: true, error: null }; - } else { - await p.waitForTimeout(700); - if (!(await clickActionByTextWithRetry(p, confirmLabels, 3000))) { - const menuTexts = await readVisibleActionTexts(p); - await p.keyboard.press("Escape").catch(() => {}); - await clearSidebarSearch(p); - return { - success: false, - error: `MAX ${actionName} confirmation was not found. Visible actions: ${menuTexts.slice(0, 12).join(" | ")}` - }; - } - await p.waitForTimeout(1200); - } - await clearSidebarSearch(p); - return { success: true, error: null }; -} - -async function waitForSidebarChatRemoval(p, externalChatId, chatUrl, timeoutMs = 10000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - if (!(await isSidebarChatPresent(p, externalChatId, chatUrl))) { - return true; - } - await p.waitForTimeout(900); - } - - return false; -} - -async function isSidebarChatPresent(p, externalChatId, chatUrl = null) { - const descriptor = decodeDomChatDescriptor(externalChatId); - if (!descriptor?.title) { - return false; - } - - const targetUrl = normalizeMaxChatUrl(chatUrl); - const sameChatUrl = (value) => { - const current = normalizeMaxChatUrl(value); - return Boolean(targetUrl && current && current === targetUrl); - }; - const visibleMatch = async () => { - const rows = await readVisibleSidebarChatRows(p); - if (!targetUrl) { - return Boolean(chooseDomChatRow(rows, descriptor)); - } - - const matches = rows.filter((row) => rowMatchesDomChatDescriptor(row, descriptor)); - if (matches.some((row) => sameChatUrl(row.href))) { - return true; - } - - for (const row of matches) { - await p.mouse.click(row.x, row.y); - await waitForDomChatReady(p, row.title, 3000, false); - await p.waitForTimeout(150); - if (sameChatUrl(await getCurrentChatUrl(p))) { - return true; - } - } - - return false; - }; - - await clearSidebarSearch(p); - await resetSidebarListToTop(p); - await waitForMaxChatListReady(p, 8000); - if (await visibleMatch()) { - return true; - } - - const searched = await searchSidebarChat(p, descriptor.title); - if (searched) { - await p.waitForTimeout(500); - if (await visibleMatch()) { - await clearSidebarSearch(p); - return true; - } - } - - await clearSidebarSearch(p); - await resetSidebarListToTop(p); - await waitForMaxChatListReady(p, 8000); - for (let pageIndex = 0; pageIndex < 220; pageIndex++) { - if (await visibleMatch()) { - return true; - } - - const moved = await scrollSidebarForward(p); - if (!moved) break; - await p.waitForTimeout(250); - } - - await clearSidebarSearch(p); - return false; -} - -async function setMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji) { - const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText); - if (!target) { - return { success: false, error: "MAX message was not found on the visible chat history." }; - } - - if (!(await openMessageMenu(p, target.rect))) { - return { success: false, error: "MAX message menu was not opened." }; - } - - if (await clickActionByText(p, [emoji])) { - await p.waitForTimeout(600); - return { success: true, error: null }; - } - - await clickActionByText(p, ["\u0440\u0435\u0430\u043a", "reaction", "emoji"]); - await p.waitForTimeout(500); - if (!(await clickActionByText(p, [emoji]))) { - return { success: false, error: "MAX reaction picker did not expose the requested emoji." }; - } - - await p.waitForTimeout(600); - return { success: true, error: null }; -} - -async function clearMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji) { - const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText); - if (!target) { - return { success: false, error: "MAX message was not found on the visible chat history." }; - } - - if (await clickReactionNearMessage(p, target.rect, emoji)) { - await p.waitForTimeout(600); - return { success: true, error: null }; - } - - if (!(await openMessageMenu(p, target.rect))) { - return { success: false, error: "MAX message menu was not opened." }; - } - - if (await clickActionByText(p, [emoji, "\u0443\u0431\u0440\u0430\u0442\u044c \u0440\u0435\u0430\u043a", "remove reaction", "clear reaction"])) { - await p.waitForTimeout(600); - return { success: true, error: null }; - } - - return { success: false, error: "MAX reaction clear action was not found." }; -} - -async function locateMessageForAction(p, externalChatId, externalMessageId, currentText) { - if (externalChatId) { - const opened = await ensureDomChatOpen(p, externalChatId, 700); - if (!opened) { - return null; - } - } - - const history = await extractOpenChatHistory(p, externalChatId); - const textHint = cleanComparableText(currentText, 500); - let message = history.messages.find((item) => item.externalId === externalMessageId); - if (!message && textHint) { - const matches = history.messages.filter((item) => { - const text = cleanComparableText(item.text, 500); - return text && (text === textHint || text.includes(textHint) || textHint.includes(text)); - }); - message = matches[matches.length - 1]; - } - - if (!message?.rect) { - return null; - } - - return message; -} - -async function openMessageMenu(p, rect) { - const point = rectCenter(rect); - await p.mouse.move(point.x, point.y); - await p.waitForTimeout(250); - - if (await clickMessageMenuButton(p, rect)) { - await p.waitForTimeout(500); - if (await hasActionSurface(p)) { - return true; - } - } - - await p.mouse.click(point.x, point.y, { button: "right" }); - await p.waitForTimeout(500); - if (await hasActionSurface(p)) { - return true; - } - - await p.mouse.dblclick(point.x, point.y); - await p.waitForTimeout(500); - return await hasActionSurface(p); -} - -async function clickMessageMenuButton(p, rect) { - return await p.evaluate((targetRect) => { - const isVisible = (element) => { - const style = window.getComputedStyle(element); - const nodeRect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - nodeRect.width > 0 && - nodeRect.height > 0 && - nodeRect.bottom >= 0 && - nodeRect.right >= 0 && - nodeRect.top <= window.innerHeight && - nodeRect.left <= window.innerWidth; - }; - const expanded = { - left: targetRect.x - 60, - right: targetRect.x + targetRect.width + 90, - top: targetRect.y - 40, - bottom: targetRect.y + targetRect.height + 40 - }; - const inArea = (element) => { - const rect = element.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - return centerX >= expanded.left && - centerX <= expanded.right && - centerY >= expanded.top && - centerY <= expanded.bottom; - }; - const buttons = Array.from(document.querySelectorAll("button,[role='button']")) - .filter((button) => button instanceof HTMLElement && isVisible(button) && inArea(button)); - const match = buttons.find((button) => { - const text = String(button.innerText || button.getAttribute("aria-label") || button.getAttribute("title") || "").toLowerCase(); - const cls = String(button.className || "").toLowerCase(); - return text.includes("\u0435\u0449") || - text.includes("more") || - text.includes("menu") || - text.includes("actions") || - cls.includes("more") || - cls.includes("menu") || - text === "\u22ef" || - text === "\u2026"; - }) || buttons.sort((a, b) => b.getBoundingClientRect().right - a.getBoundingClientRect().right)[0]; - - if (!match) { - return false; - } - - match.click(); - return true; - }, rect).catch(() => false); -} - -async function openChatMenu(p, expectedLabels) { - for (let attempt = 0; attempt < 3; attempt++) { - if (await clickChatMenuButton(p)) { - await p.waitForTimeout(600); - if (await hasMenuActionSurface(p, expectedLabels)) { - return true; - } - } - - await p.keyboard.press("Escape").catch(() => {}); - await p.waitForTimeout(250); - } - - return false; -} - -async function clickChatMenuButton(p) { - return await p.evaluate(() => { - const isVisible = (element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const textOf = (element) => String( - element.innerText || - element.textContent || - element.getAttribute("aria-label") || - element.getAttribute("title") || - "" - ).trim().toLowerCase(); - const chatRoot = document.querySelector("[class*='openedChat' i]") || - document.querySelector("main") || - document.body; - const rootRect = chatRoot.getBoundingClientRect(); - const headerBottom = Math.min(window.innerHeight, rootRect.top + 170); - const buttons = Array.from(document.querySelectorAll("button,[role='button']")) - .filter((button) => { - if (!(button instanceof HTMLElement) || !isVisible(button)) return false; - const rect = button.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - return centerX >= rootRect.left + rootRect.width * 0.45 && - centerX <= rootRect.right + 8 && - centerY >= Math.max(0, rootRect.top) && - centerY <= headerBottom; - }); - const match = buttons.find((button) => { - const text = textOf(button); - const cls = String(button.className || "").toLowerCase(); - return text.includes("\u0435\u0449") || - text.includes("more") || - text.includes("menu") || - text.includes("actions") || - cls.includes("more") || - cls.includes("menu") || - text === "\u22ef" || - text === "\u2026"; - }) || buttons.sort((a, b) => b.getBoundingClientRect().right - a.getBoundingClientRect().right)[0]; - - if (!match) { - return false; - } - - match.click(); - return true; - }).catch(() => false); -} - -async function hasMenuActionSurface(p, labels) { - return await p.evaluate((rawLabels) => { - const normalizedLabels = rawLabels.map((label) => String(label || "").trim().toLowerCase()).filter(Boolean); - if (normalizedLabels.length === 0) { - return false; - } - const isVisible = (element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const textOf = (element) => String( - element.innerText || - element.textContent || - element.getAttribute("aria-label") || - element.getAttribute("title") || - "" - ).trim().toLowerCase(); - return Array.from(document.querySelectorAll("[role='menu'],[role='menuitem'],[class*='menu' i],[class*='popover' i],[class*='dropdown' i],button,span,div")) - .filter((element) => element instanceof HTMLElement && isVisible(element)) - .some((element) => { - const text = textOf(element); - return text && normalizedLabels.some((label) => text === label || text.includes(label)); - }); - }, labels).catch(() => false); -} - -async function hasAnyChatMenuSurface(p) { - const texts = await readVisibleActionTexts(p); - return texts.some((text) => - /(\u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0432 \u043f\u0430\u043f\u043a\u0443|\u0437\u0430\u043a\u0440\u0435\u043f\u0438\u0442\u044c|\u043e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043d\u0435\u043f\u0440\u043e\u0447\u0438\u0442\u0430\u043d\u043d\u044b\u043c|\u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f|\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u0438\u0441\u0442\u043e\u0440|\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442|\u0432\u044b\u0439\u0442\u0438 \u0438\u0437 \u0447\u0430\u0442\u0430|\u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c|add to folder|pin|mark unread|mute|clear history|delete chat|leave chat|block)/i - .test(String(text || ""))); -} - -async function readVisibleActionTexts(p) { - return await p.evaluate(() => { - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const textOf = (element) => String( - element.innerText || - element.textContent || - element.getAttribute("aria-label") || - element.getAttribute("title") || - "" - ).replace(/\s+/g, " ").trim(); - const selector = [ - "[role='menu']", - "[role='menuitem']", - "[class*='menu' i]", - "[class*='popover' i]", - "[class*='dropdown' i]", - "button", - "[role='button']" - ].join(","); - const seen = new Set(); - return Array.from(document.querySelectorAll(selector)) - .filter((element) => element instanceof HTMLElement && isVisible(element)) - .map(textOf) - .filter(Boolean) - .filter((text) => { - if (seen.has(text)) return false; - seen.add(text); - return true; - }) - .slice(0, 80); - }).catch(() => []); -} - -async function hasActionSurface(p) { - return await p.evaluate(() => { - const isVisible = (element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const labels = /(\u0440\u0435\u0434\u0430\u043a\u0442|\u0438\u0437\u043c\u0435\u043d|\u0443\u0434\u0430\u043b|\u0440\u0435\u0430\u043a|edit|delete|remove|reaction)/i; - return Array.from(document.querySelectorAll("[role='menu'],[role='menuitem'],[class*='menu' i],[class*='popover' i],[class*='dropdown' i],button")) - .filter((element) => element instanceof HTMLElement && isVisible(element)) - .some((element) => labels.test(element.innerText || element.textContent || element.getAttribute("aria-label") || "")); - }).catch(() => false); -} - -async function clickActionByText(p, labels) { - return await p.evaluate((rawLabels) => { - const normalizedLabels = rawLabels.map((label) => String(label || "").trim().toLowerCase()).filter(Boolean); - if (normalizedLabels.length === 0) { - return false; - } - const isVisible = (element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const textOf = (element) => String( - element.innerText || - element.textContent || - element.getAttribute("aria-label") || - element.getAttribute("title") || - "" - ).trim().toLowerCase(); - const scoreMatch = (element) => { - const text = textOf(element); - if (!text) return 0; - if (normalizedLabels.some((label) => text === label)) return 4; - if (normalizedLabels.some((label) => text.startsWith(label))) return 3; - if (normalizedLabels.some((label) => text.includes(label))) return 2; - return 0; - }; - const interactive = Array.from(document.querySelectorAll("button,[role='button'],[role='menuitem']")) - .filter((element) => element instanceof HTMLElement && isVisible(element)); - const leafTextNodes = Array.from(document.querySelectorAll("span,div")) - .filter((element) => element instanceof HTMLElement && isVisible(element)) - .filter((element) => element.querySelector("button,[role='button'],[role='menuitem']") == null) - .filter((element) => element.children.length <= 1); - const match = [...interactive, ...leafTextNodes] - .map((element, index) => ({ element, index, score: scoreMatch(element) })) - .filter((item) => item.score > 0) - .sort((a, b) => b.score - a.score || a.index - b.index)[0]?.element || null; - if (!match) { - return false; - } - - const clickable = match.closest("button,[role='button'],[role='menuitem']") || match; - clickable.click(); - return true; - }, labels).catch(() => false); -} - -async function clickActionByTextWithRetry(p, labels, timeoutMs = 2000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - if (await clickActionByText(p, labels)) { - return true; - } - await p.waitForTimeout(200); - } - - return false; -} - -async function clickActionByTextWithMouseRetry(p, labels, timeoutMs = 2000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - const point = await findActionClickPoint(p, labels); - if (point) { - await p.mouse.move(point.x, point.y); - await p.waitForTimeout(80); - await p.mouse.click(point.x, point.y); - return { clicked: true, text: point.text }; - } - await p.waitForTimeout(200); - } - - return { clicked: false, text: null }; -} - -async function findActionClickPoint(p, labels) { - return await p.evaluate((rawLabels) => { - const normalizedLabels = rawLabels.map((label) => String(label || "").trim().toLowerCase()).filter(Boolean); - if (normalizedLabels.length === 0) { - return null; - } - - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const textOf = (element) => String( - element.innerText || - element.textContent || - element.getAttribute("aria-label") || - element.getAttribute("title") || - "" - ).replace(/\s+/g, " ").trim().toLowerCase(); - const originalTextOf = (element) => String( - element.innerText || - element.textContent || - element.getAttribute("aria-label") || - element.getAttribute("title") || - "" - ).replace(/\s+/g, " ").trim(); - const scoreMatch = (element) => { - const text = textOf(element); - if (!text) return 0; - if (normalizedLabels.some((label) => text === label)) return 120; - if (normalizedLabels.some((label) => text.startsWith(label))) return 90 - Math.min(30, text.length); - if (normalizedLabels.some((label) => text.includes(label))) return 60 - Math.min(30, text.length); - return 0; - }; - const surfaceSelector = [ - "[role='menu']", - "[role='menuitem']", - "[class*='menu' i]", - "[class*='popover' i]", - "[class*='dropdown' i]", - "[role='dialog']", - "[aria-modal='true']", - "[class*='modal' i]", - "[class*='dialog' i]" - ].join(","); - const interactive = Array.from(document.querySelectorAll("button,[role='button'],[role='menuitem']")) - .filter((element) => element instanceof HTMLElement && isVisible(element) && !element.disabled); - const leafTextNodes = Array.from(document.querySelectorAll("span,div")) - .filter((element) => element instanceof HTMLElement && isVisible(element)) - .filter((element) => element.querySelector("button,[role='button'],[role='menuitem']") == null) - .filter((element) => element.children.length <= 1); - const match = [...interactive, ...leafTextNodes] - .map((element, index) => { - const clickable = element.closest("button,[role='button'],[role='menuitem']") || element; - const rect = clickable.getBoundingClientRect(); - let score = scoreMatch(element); - if (score <= 0) return null; - if (clickable.closest(surfaceSelector)) score += 30; - if (clickable.matches("button")) score += 10; - return { - element: clickable, - index, - score, - text: originalTextOf(element), - x: Math.round(rect.left + rect.width / 2), - y: Math.round(rect.top + rect.height / 2), - length: textOf(element).length - }; - }) - .filter(Boolean) - .sort((a, b) => b.score - a.score || a.length - b.length || a.index - b.index)[0] || null; - - return match - ? { x: match.x, y: match.y, text: match.text } - : null; - }, labels).catch(() => null); -} - -async function clickDestructiveConfirmationByTextWithRetry(p, labels, timeoutMs = 2000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - const result = await clickDestructiveConfirmationByText(p, labels); - if (result.clicked) { - return result; - } - await p.waitForTimeout(200); - } - - return { clicked: false, text: null }; -} - -async function clickDestructiveConfirmationByText(p, labels) { - const point = await p.evaluate((rawLabels) => { - const normalizedLabels = rawLabels.map((label) => String(label || "").trim().toLowerCase()).filter(Boolean); - if (normalizedLabels.length === 0) { - return null; - } - - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const textOf = (element) => String( - element.innerText || - element.textContent || - element.getAttribute("aria-label") || - element.getAttribute("title") || - "" - ).replace(/\s+/g, " ").trim().toLowerCase(); - const originalTextOf = (element) => String( - element.innerText || - element.textContent || - element.getAttribute("aria-label") || - element.getAttribute("title") || - "" - ).replace(/\s+/g, " ").trim(); - const scoreLabel = (text) => { - let best = 0; - for (const label of normalizedLabels) { - if (text === label) best = Math.max(best, 120); - else if (text.startsWith(`${label} `)) best = Math.max(best, 80 - Math.min(30, text.length - label.length)); - else if (text.includes(label)) best = Math.max(best, 50 - Math.min(25, text.length - label.length)); - } - return best; - }; - const destructive = /(\u0443\u0434\u0430\u043b|delete|remove)/i; - const cancel = /(\u043e\u0442\u043c\u0435\u043d|cancel|back|close)/i; - const dialogSelector = [ - "[role='dialog']", - "[role='alertdialog']", - "[aria-modal='true']", - "[class*='modal' i]", - "[class*='dialog' i]", - "[class*='confirm' i]", - "[class*='popup' i]" - ].join(","); - const menuSelector = [ - "[role='menu']", - "[role='menuitem']", - "[class*='menu' i]", - "[class*='actionsMenu' i]", - "[class*='dropdown' i]" - ].join(","); - - const candidates = Array.from(document.querySelectorAll("button,[role='button']")) - .filter((element) => element instanceof HTMLElement && isVisible(element) && !element.disabled) - .map((element, index) => { - const text = textOf(element); - const originalText = originalTextOf(element); - let score = scoreLabel(text); - if (score <= 0) return null; - const inDialog = Boolean(element.closest(dialogSelector)); - const inMenu = Boolean(element.closest(menuSelector)); - if (inMenu && !inDialog) return null; - if (cancel.test(text)) score -= 200; - if (destructive.test(text)) score += 15; - if (element.matches("button")) score += 12; - if (element.getAttribute("role") === "button") score += 8; - if (inDialog) score += 120; - const rect = element.getBoundingClientRect(); - return { - element, - index, - originalText, - score, - top: rect.top, - left: rect.left, - length: text.length, - inDialog, - inMenu - }; - }) - .filter(Boolean) - .sort((a, b) => - Number(b.inDialog) - Number(a.inDialog) || - b.score - a.score || - a.length - b.length || - b.top - a.top || - b.left - a.left || - a.index - b.index); - - const match = candidates[0] || null; - if (!match || match.score <= 0) { - return null; - } - - const rect = match.element.getBoundingClientRect(); - return { - x: Math.round(rect.left + rect.width / 2), - y: Math.round(rect.top + rect.height / 2), - text: match.originalText - }; - }, labels).catch(() => null); - - if (!point) { - return { clicked: false, text: null }; - } - - await p.mouse.move(point.x, point.y); - await p.waitForTimeout(80); - await p.mouse.click(point.x, point.y); - return { clicked: true, text: point.text }; -} - -async function clickReactionNearMessage(p, rect, emoji) { - return await p.evaluate(({ targetRect, requestedEmoji }) => { - const isVisible = (element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const expanded = { - left: targetRect.x - 40, - right: targetRect.x + targetRect.width + 80, - top: targetRect.y - 20, - bottom: targetRect.y + targetRect.height + 70 - }; - const inArea = (element) => { - const rect = element.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - return centerX >= expanded.left && - centerX <= expanded.right && - centerY >= expanded.top && - centerY <= expanded.bottom; - }; - const match = Array.from(document.querySelectorAll("button,[role='button'],span,div")) - .filter((element) => element instanceof HTMLElement && isVisible(element) && inArea(element)) - .find((element) => String(element.innerText || element.textContent || element.getAttribute("aria-label") || "").includes(requestedEmoji)); - if (!match) { - return false; - } - const clickable = match.closest("button,[role='button']") || match; - clickable.click(); - return true; - }, { targetRect: rect, requestedEmoji: emoji }).catch(() => false); -} - -function rectCenter(rect) { - return { - x: Math.round(Number(rect.x || 0) + Number(rect.width || 0) / 2), - y: Math.round(Number(rect.y || 0) + Number(rect.height || 0) / 2) - }; -} - -function normalizeDomChatFingerprint(value) { - const raw = cleanComparableText(value, 800); - if (!raw || raw.startsWith("data:")) { - return ""; - } - - try { - const url = new URL(raw); - const mediaKey = url.searchParams.get("r"); - if (mediaKey) { - return `r:${mediaKey}`; - } - - ["fn", "size", "width", "height"].forEach((key) => url.searchParams.delete(key)); - return url.href; - } catch { - return raw; - } -} - -function makeDomChatExternalId(title, text, href, index, avatarUrl) { - const descriptor = { - title: cleanComparableText(title, 160), - avatarUrl: normalizeDomChatFingerprint(avatarUrl), - href: "" - }; - const encodedDescriptor = Buffer.from(JSON.stringify(descriptor), "utf8") - .toString("base64url"); - return `dom:${stableHash("chat", descriptor.title, descriptor.href, descriptor.avatarUrl)}:${encodedDescriptor}`; -} - -function decodeDomChatDescriptor(externalChatId) { - const parts = String(externalChatId || "").split(":"); - if (parts.length < 3 || parts[0] !== "dom") { - return null; - } - - try { - const decoded = Buffer.from(parts[2], "base64url").toString("utf8"); - const parsed = JSON.parse(decoded); - if (parsed && typeof parsed === "object") { - const title = cleanComparableText(parsed.title, 160); - if (!title) { - return null; - } - - return { - title, - avatarUrl: cleanComparableText(parsed.avatarUrl, 800), - href: cleanComparableText(parsed.href, 800) - }; - } - } catch { - try { - const title = Buffer.from(parts[2], "base64url").toString("utf8"); - return title ? { title, avatarUrl: "", href: "" } : null; - } catch { - return null; - } - } - - return null; -} - -function decodeDomChatTitle(externalChatId) { - return decodeDomChatDescriptor(externalChatId)?.title || null; -} - -function stableHash(...parts) { - return createHash("sha256") - .update(parts.map((part) => String(part ?? "")).join("\n")) - .digest("hex") - .slice(0, 24); -} - -function clampNumber(value, min, max, fallback) { - const number = Number(value); - if (!Number.isFinite(number)) { - return fallback; - } - - return Math.max(min, Math.min(max, Math.trunc(number))); -} - -async function fillFirst(p, value, selectors) { - for (const selector of selectors) { - try { - const locator = p.locator(selector).first(); - if ((await locator.count()) === 0) continue; - await clearAndInsertText(p, locator, value); - return true; - } catch { - // Try the next likely selector. - } - } - return false; -} - -async function clearAndInsertText(p, locator, text) { - await locator.click({ timeout: 5000 }); - await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); - await p.keyboard.press("Backspace"); - await p.keyboard.insertText(String(text)); -} - -async function fillLoginCode(p, code) { - const filled = await p.evaluate((value) => { - const isVisible = (element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && - style.display !== "none" && - rect.width > 0 && - rect.height > 0 && - rect.bottom >= 0 && - rect.right >= 0 && - rect.top <= window.innerHeight && - rect.left <= window.innerWidth; - }; - const setControlValue = (node, nextValue) => { - if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) { - const proto = node instanceof HTMLTextAreaElement - ? HTMLTextAreaElement.prototype - : HTMLInputElement.prototype; - const descriptor = Object.getOwnPropertyDescriptor(proto, "value"); - if (descriptor?.set) { - descriptor.set.call(node, nextValue); - } else { - node.value = nextValue; - } - } else { - node.textContent = nextValue; - } - node.dispatchEvent(new InputEvent("input", { bubbles: true, data: nextValue, inputType: "insertText" })); - node.dispatchEvent(new Event("change", { bubbles: true })); - }; - - const inputs = Array.from(document.querySelectorAll("input, textarea, [contenteditable='true']")) - .filter((node) => node instanceof HTMLElement && isVisible(node)) - .filter((node) => { - const type = String(node.getAttribute("type") || "").toLowerCase(); - return !["hidden", "submit", "button", "checkbox", "radio"].includes(type); - }); - const codeInputs = inputs.filter((node) => { - const hint = [ - node.getAttribute("autocomplete"), - node.getAttribute("name"), - node.getAttribute("placeholder"), - node.getAttribute("aria-label"), - node.className, - node.getAttribute("inputmode") - ].join(" "); - return /one-time|otp|code|pin|verification|numeric/i.test(hint) || - Number(node.getAttribute("maxlength") || 0) <= 1; - }); - const targets = codeInputs.length > 0 ? codeInputs : inputs; - if (targets.length === 0) { - return false; - } - if (targets.length > 1 && targets.every((node) => Number(node.getAttribute("maxlength") || 1) <= 1)) { - for (let index = 0; index < Math.min(value.length, targets.length); index += 1) { - targets[index].focus(); - setControlValue(targets[index], value[index]); - } - return true; - } - targets[0].focus(); - setControlValue(targets[0], value); - return true; - }, code).catch(() => false); - - if (filled) { - return true; - } - - const inputFilled = await fillFirst(p, code, [ - "input[autocomplete='one-time-code']", - "input[inputmode='numeric']", - "input[type='tel']", - "input[type='text']", - "input:not([type])", - "input" - ]); - if (inputFilled) { - return true; - } - - for (const selector of ["[role='group'].code", "[role='group'][class*='code' i]", ".code", "[role='group']"]) { - try { - const group = p.locator(selector).first(); - if ((await group.count()) === 0) continue; - await group.click({ timeout: 3000 }); - await p.keyboard.insertText(code); - return true; - } catch { - // Try the next OTP container shape. - } - } - - return false; -} - -async function openPhoneLogin(p) { - if (await hasPhoneInput(p)) { - return; - } - - const phoneLoginPattern = /(\u0432\u043e\u0439\u0442\u0438.*\u043d\u043e\u043c\u0435\u0440|\u043d\u043e\u043c\u0435\u0440.*\u0442\u0435\u043b\u0435\u0444\u043e\u043d|phone)/i; - try { - const byText = p.getByText(phoneLoginPattern).last(); - if ((await byText.count()) > 0) { - await byText.click({ timeout: 3000 }); - await p.waitForTimeout(1200); - } - } catch { - // Fall through to DOM text matching below. - } - - if (await hasPhoneInput(p)) { - return; - } - - await p.evaluate(() => { - const phoneLoginPattern = /(\u0432\u043e\u0439\u0442\u0438.*\u043d\u043e\u043c\u0435\u0440|\u043d\u043e\u043c\u0435\u0440.*\u0442\u0435\u043b\u0435\u0444\u043e\u043d|phone)/i; - const elements = Array.from(document.querySelectorAll("button,a,[role=button],div,span")); - const match = elements.find((element) => phoneLoginPattern.test(element.textContent || "")); - if (match) { - match.click(); - } - }); - await p.waitForTimeout(1200); -} - -async function hasPhoneInput(p) { - return (await p.locator("input[type='tel'], input[autocomplete='tel']").count()) > 0; -} - -async function waitForLoginStage(p, expectedStage, timeoutMs) { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - const stage = await detectLoginStage(p); - if (stage === expectedStage) { - return true; - } - - if (timeoutMs === 0) { - return false; - } - - await p.waitForTimeout(1000); - } - - return false; -} - -async function detectLoginStage(p) { - const title = await safeTitle(p); - const url = p.url(); - const bodyText = await safeBodyText(p); - return detectLoginStageFromText(url, title, bodyText); -} - -function detectLoginStageFromText(url, title, bodyText) { - const currentUrl = String(url || "").toLowerCase(); - const currentTitle = String(title || "").toLowerCase(); - const body = String(bodyText || "").replace(/\s+/g, " ").trim().toLowerCase(); - - if (!body && currentTitle.includes("max") && currentUrl.includes("web.max.ru")) { - return "Loading"; - } - - if (body.includes("\u043d\u0435 \u0440\u043e\u0431\u043e\u0442")) { - return "Captcha"; - } - - if (body.includes("qr") || body.includes("\u0432\u043e\u0439\u0434\u0438\u0442\u0435 \u0432 max \u043f\u043e qr")) { - return "QrLogin"; - } - - const looksLikeAuthorizedShell = currentUrl.includes("web.max.ru") && - (/web\.max\.ru\/\d+/.test(currentUrl) || - currentTitle.includes("\u0447\u0430\u0442") || - currentTitle.includes("\u043d\u0435\u043f\u0440\u043e\u0447\u0438\u0442\u0430\u043d") || - currentTitle.includes("max")); - if (looksLikeAuthorizedShell && !currentUrl.includes("login")) { - return "Authorized"; - } - - const phoneScreenMarkers = [ - "\u0441 \u043a\u0430\u043a\u0438\u043c \u043d\u043e\u043c\u0435\u0440\u043e\u043c", - "\u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430", - "\u043d\u0430 \u043d\u0435\u0433\u043e \u043f\u0440\u0438\u0434\u0435\u0442 \u0441\u043c\u0441 \u0441 \u043a\u043e\u0434\u043e\u043c" - ]; - if (phoneScreenMarkers.some((marker) => body.includes(marker))) { - return "PhoneEntry"; - } - - const codeMarkers = [ - "\u043e\u0442\u043f\u0440\u0430\u0432\u0438\u043b\u0438 \u043a\u043e\u0434", - "\u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434", - "\u043a\u043e\u0434 \u0438\u0437 sms", - "\u043a\u043e\u0434 \u0438\u0437 \u0441\u043c\u0441", - "\u043a\u043e\u0434 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434", - "\u043a\u043e\u0434 \u0438\u0437 \u0441\u043e\u043e\u0431\u0449\u0435\u043d", - "\u0435\u0441\u043b\u0438 \u043d\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 \u0441\u043c\u0441", - "\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u043e\u0432\u044b\u0439 \u043a\u043e\u0434" - ]; - if (codeMarkers.some((marker) => body.includes(marker))) { - return "CodeEntry"; - } - - const loginMarkers = [ - "\u0432\u043e\u0439\u0442\u0438", - "\u043f\u043e\u043b\u0438\u0442\u0438\u043a", - "\u0441\u043e\u0433\u043b\u0430\u0448\u0435\u043d" - ]; - if (loginMarkers.some((marker) => body.includes(marker))) { - return "Login"; - } - - if (looksLikeAuthorizedShell) { - return "Authorized"; - } - - return "Unknown"; -} - -async function isCodeEntryScreen(p) { - return await p.evaluate(() => { - const text = String(document.body?.innerText || "").replace(/\s+/g, " ").trim().toLowerCase(); - const inputs = Array.from(document.querySelectorAll("input")); - const hasOneTimeCodeInput = inputs.some((input) => - String(input.getAttribute("autocomplete") || "").toLowerCase() === "one-time-code"); - if (hasOneTimeCodeInput) { - return true; - } - - if (text.includes("\u043d\u0435 \u0440\u043e\u0431\u043e\u0442")) { - return false; - } - - const phoneScreenMarkers = [ - "\u0441 \u043a\u0430\u043a\u0438\u043c \u043d\u043e\u043c\u0435\u0440\u043e\u043c", - "\u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430", - "\u043d\u0430 \u043d\u0435\u0433\u043e \u043f\u0440\u0438\u0434\u0435\u0442 \u0441\u043c\u0441 \u0441 \u043a\u043e\u0434\u043e\u043c" - ]; - if (phoneScreenMarkers.some((marker) => text.includes(marker))) { - return false; - } - - const codeMarkers = [ - "\u043e\u0442\u043f\u0440\u0430\u0432\u0438\u043b\u0438 \u043a\u043e\u0434", - "\u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434", - "\u043a\u043e\u0434 \u0438\u0437 sms", - "\u043a\u043e\u0434 \u0438\u0437 \u0441\u043c\u0441", - "\u043a\u043e\u0434 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434", - "\u043a\u043e\u0434 \u0438\u0437 \u0441\u043e\u043e\u0431\u0449\u0435\u043d", - "\u0435\u0441\u043b\u0438 \u043d\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 \u0441\u043c\u0441", - "\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u043e\u0432\u044b\u0439 \u043a\u043e\u0434" - ]; - return inputs.length > 0 && codeMarkers.some((marker) => text.includes(marker)); - }); -} - -function normalizePhoneNumberForMax(phoneNumber) { - const digits = String(phoneNumber || "").replace(/\D/g, ""); - if (digits.length === 11 && (digits.startsWith("8") || digits.startsWith("7"))) { - return digits.slice(1); - } - return digits || String(phoneNumber || "").trim(); -} - -function normalizeLoginCode(value) { - return String(value || "").replace(/\D/g, "").trim(); -} - -async function cleanupChromiumProfileLocks(profileDir) { - await Promise.all(["SingletonLock", "SingletonCookie", "SingletonSocket"].map((name) => - fs.rm(`${profileDir}/${name}`, { force: true, recursive: true }).catch(() => {}) - )); -} - -async function clickLikelySubmitFixed(p) { - const labels = [ - "\u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c", - "\u0432\u043e\u0439\u0442\u0438", - "\u0434\u0430\u043b\u0435\u0435", - "\u043e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c", - "continue", - "next", - "login" - ]; - const clicked = await p.evaluate((knownLabels) => { - const normalized = knownLabels.map((x) => x.toLowerCase()); - const buttons = Array.from(document.querySelectorAll("button,[role=button],input[type=submit]")); - const match = buttons.find((button) => { - const text = (button.innerText || button.value || button.getAttribute("aria-label") || "").trim().toLowerCase(); - return !button.disabled && text && normalized.some((label) => text.includes(label)); - }) || buttons.find((button) => !button.disabled); - if (match) { - match.click(); - return true; - } - return false; - }, labels); - - if (!clicked) { - await p.keyboard.press("Enter"); - } -} - -async function clickLikelySubmit(p) { - const labels = ["продолжить", "войти", "далее", "отправить", "continue", "next", "login"]; - const clicked = await p.evaluate((knownLabels) => { - const normalized = knownLabels.map((x) => x.toLowerCase()); - const buttons = Array.from(document.querySelectorAll("button,[role=button],input[type=submit]")); - const match = buttons.find((button) => { - const text = (button.innerText || button.value || button.getAttribute("aria-label") || "").trim().toLowerCase(); - return text && normalized.some((label) => text.includes(label)); - }) || buttons.find((button) => !button.disabled); - if (match) { - match.click(); - return true; - } - return false; - }, labels); - - if (!clicked) { - await p.keyboard.press("Enter"); - } -} - -async function clickTextIfVisible(p, text) { - try { - const locator = p.getByText(text, { exact: false }).first(); - if ((await locator.count()) > 0) { - await locator.click({ timeout: 1500 }); - } - } catch { - // Chat may already be open. - } -} - -async function safeTitle(p) { - try { - return await p.title(); - } catch { - return null; - } -} - -async function safeBodyText(p) { - try { - return (await p.locator("body").innerText({ timeout: 1500 })).replace(/\s+/g, " ").trim(); - } catch { - return ""; - } -} - -function likelyAuthorized(url, title, bodyText) { - if (!url) return false; - if (url.toLowerCase().includes("login")) return false; - const body = String(bodyText || "").toLowerCase(); - const loginMarkers = [ - "\u043d\u0435 \u0440\u043e\u0431\u043e\u0442", - "\u0432\u043e\u0439\u0442\u0438", - "\u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430", - "qr", - "\u043a\u043e\u0434\u043e\u043c" - ]; - if (loginMarkers.some((marker) => body.includes(marker))) return false; - return String(title || "").toLowerCase().includes("max"); -}