commit 9fcc65943889dab9e07697eeacf1a8bd9f7f49be Author: sevenhill Date: Fri Jul 3 22:39:48 2026 +0300 Initial QMAX production app diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..42e7d57 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.gradle +android/.gradle +android/app/build +android/build +**/bin +**/obj +**/node_modules +data +storage +secrets +.env +.env.* +!deploy/.env.example +qmax-*-firebase-adminsdk-*.json +google-services.json +*.jks +*.keystore +key.properties diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..842a7a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +bin/ +obj/ +.vs/ +.idea/ +.gradle/ +build/ +artifacts/ +node_modules/ +local.properties +*.user +*.suo +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite-shm +*.sqlite-wal +*.part +*.hprof + +# QMAX runtime data and secrets +data/ +storage/ +secrets/ +.env +.env.* +!.env.example +appsettings.Production.local.json +qmax-*-firebase-adminsdk-*.json +firebase-adminsdk*.json +service-account*.json +*.keystore +*.jks +key.properties +!qmax-*-firebase-adminsdk-*.json +!firebase-adminsdk*.json +!service-account*.json +!android/qmax-release.jks +!android/key.properties + +# Firebase Android config may be used locally, but should be reviewed before publishing. +google-services.json +!google-services.json +!android/app/google-services.json + +# Android/IDE output +android/.kotlin/ +android/captures/ +android/app/release/ +android/app/debug/ diff --git a/ARGUS_PUBLICATION.md b/ARGUS_PUBLICATION.md new file mode 100644 index 0000000..dbd8fce --- /dev/null +++ b/ARGUS_PUBLICATION.md @@ -0,0 +1,343 @@ +# Argus Publication And Update Integration + +Эта инструкция является единой точкой входа для любого приложения, которое нужно публиковать через Argus или подключить к системе обновлений Argus. + +Argus в текущей архитектуре является read-only web catalog. У него нет HTTP admin API, upload endpoint, токена публикации или браузерной формы публикации. Публикация выполняется только на сервере через SSH: файл релиза кладётся в `Data/Packages`, а метаданные записываются в SQLite `Data/argus.db`. + +## Production Argus + +- Public base URL: `https://argus.kusoft.xyz` +- SSH host: `192.168.0.185` +- SSH user: `sevenhill` +- Server project path: `/home/sevenhill/argus` +- Data path: `/srv/argus-data` +- SQLite database: `/srv/argus-data/argus.db` +- Package root: `/srv/argus-data/Packages` + +Do not put SSH passwords, private keys, or local `.env` files into application repositories. + +## App Values + +Every app must choose stable values: + +- `slug`: public Argus id. Use lowercase letters, digits, and hyphens only, for example `keeper-android` or `my-desktop-app`. +- `name`: display name. +- `summary`: short catalog text. +- `description`: full catalog text. +- `version`: release version. Semantic versions are recommended, for example `1.2.3`. +- `channel`: release channel, normally `stable`. +- `platform`: target platform, for example `windows-x64`, `linux-arm64`, `android`, `web`. +- `packageKind`: package type, for example `zip`, `msi`, `deb`, `apk`, `binary`. + +Argus selects the latest release by semantic version first, then by publish time. The running service currently prunes to one retained release per app on service startup. + +## Publish A Release Over SSH + +From the development machine, copy the built artifact to the Pi: + +```bash +scp ./path/to/my-app-1.2.3.zip sevenhill@192.168.0.185:/tmp/my-app-1.2.3.zip +``` + +Then connect to the Pi: + +```bash +ssh sevenhill@192.168.0.185 +``` + +Run this publication block on the Pi after editing the variables at the top: + +```bash +set -euo pipefail + +export ARGUS_DATA="/srv/argus-data" +export SOURCE_FILE="/tmp/my-app-1.2.3.zip" + +export ARGUS_SLUG="my-app" +export ARGUS_NAME="My App" +export ARGUS_SUMMARY="Short summary for the Argus catalog." +export ARGUS_DESCRIPTION="Full description shown in the Argus catalog." +export ARGUS_REPOSITORY_URL="" +export ARGUS_HOMEPAGE_URL="" +export ARGUS_IS_LISTED="1" + +export ARGUS_VERSION="1.2.3" +export ARGUS_CHANNEL="stable" +export ARGUS_PLATFORM="windows-x64" +export ARGUS_PACKAGE_KIND="zip" +export ARGUS_NOTES="What changed in this release." + +python3 - "$SOURCE_FILE" <<'PY' +import hashlib +import mimetypes +import os +import re +import shutil +import sqlite3 +import sys +import uuid +from datetime import datetime, timezone +from pathlib import Path + +source = Path(sys.argv[1]).resolve() +data_root = Path(os.environ.get("ARGUS_DATA", "/srv/argus-data")).resolve() +db_path = data_root / "argus.db" +packages_root = data_root / "Packages" + +slug = os.environ["ARGUS_SLUG"].strip() +name = os.environ["ARGUS_NAME"].strip() +summary = os.environ["ARGUS_SUMMARY"].strip() +description = os.environ["ARGUS_DESCRIPTION"].strip() +repository_url = os.environ.get("ARGUS_REPOSITORY_URL", "").strip() or None +homepage_url = os.environ.get("ARGUS_HOMEPAGE_URL", "").strip() or None +is_listed = 1 if os.environ.get("ARGUS_IS_LISTED", "1").strip() != "0" else 0 + +version = os.environ["ARGUS_VERSION"].strip() +channel = os.environ.get("ARGUS_CHANNEL", "stable").strip().lower() +platform = os.environ.get("ARGUS_PLATFORM", "generic").strip().lower() +package_kind = os.environ.get("ARGUS_PACKAGE_KIND", "binary").strip().lower() +notes = os.environ.get("ARGUS_NOTES", "").strip() or None + +if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,99}", slug): + raise SystemExit("ARGUS_SLUG must contain only lowercase letters, digits, and hyphens, max 100 chars.") +if not source.is_file(): + raise SystemExit(f"SOURCE_FILE does not exist: {source}") +if not db_path.is_file(): + raise SystemExit(f"Argus database does not exist: {db_path}") +for key, value in { + "ARGUS_NAME": name, + "ARGUS_SUMMARY": summary, + "ARGUS_DESCRIPTION": description, + "ARGUS_VERSION": version, +}.items(): + if not value: + raise SystemExit(f"{key} is required.") + +release_id = str(uuid.uuid4()).upper() +now = datetime.now(timezone.utc).isoformat(timespec="microseconds") +safe_version = re.sub(r"[^a-zA-Z0-9._-]+", "-", version).strip("-._") or "release" +extension = source.suffix or ".bin" +stored_name = f"{datetime.now(timezone.utc):%Y%m%d%H%M%S}-{safe_version}-{release_id.replace('-', '')}{extension}" +stored_relative_path = f"{slug}/{stored_name}" +target_dir = packages_root / slug +target_path = target_dir / stored_name + +target_dir.mkdir(parents=True, exist_ok=True) +shutil.copy2(source, target_path) + +size_bytes = target_path.stat().st_size +sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest() +content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream" +if source.suffix.lower() == ".apk": + content_type = "application/vnd.android.package-archive" + +conn = sqlite3.connect(db_path) +try: + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("BEGIN") + + row = conn.execute('SELECT "Id" FROM "Apps" WHERE "Slug" = ?', (slug,)).fetchone() + if row is None: + app_id = str(uuid.uuid4()).upper() + conn.execute( + ''' + INSERT INTO "Apps" + ("Id", "Slug", "Name", "Summary", "Description", "RepositoryUrl", "HomepageUrl", "IsListed", "CreatedAt", "UpdatedAt") + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', + (app_id, slug, name, summary, description, repository_url, homepage_url, is_listed, now, now), + ) + else: + app_id = row[0] + conn.execute( + ''' + UPDATE "Apps" + SET "Name" = ?, + "Summary" = ?, + "Description" = ?, + "RepositoryUrl" = ?, + "HomepageUrl" = ?, + "IsListed" = ?, + "UpdatedAt" = ? + WHERE "Id" = ? + ''', + (name, summary, description, repository_url, homepage_url, is_listed, now, app_id), + ) + + duplicate = conn.execute( + ''' + SELECT "Id" + FROM "Releases" + WHERE "CatalogAppId" = ? AND "Version" = ? AND "Channel" = ? AND "Platform" = ? + ''', + (app_id, version, channel, platform), + ).fetchone() + if duplicate is not None: + raise RuntimeError(f"Release already exists for {slug} {version} {channel} {platform}.") + + conn.execute( + ''' + INSERT INTO "Releases" + ("Id", "CatalogAppId", "Version", "Channel", "Platform", "PackageKind", + "OriginalFileName", "StoredRelativePath", "ContentType", "PackageSizeBytes", + "Sha256", "Notes", "PublishedAt") + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', + ( + release_id, + app_id, + version, + channel, + platform, + package_kind, + source.name, + stored_relative_path, + content_type, + size_bytes, + sha256, + notes, + now, + ), + ) + + conn.commit() +except Exception: + conn.rollback() + try: + target_path.unlink() + except FileNotFoundError: + pass + raise +finally: + conn.close() + +print(f"Published app={slug} version={version} channel={channel} platform={platform}") +print(f"StoredRelativePath={stored_relative_path}") +print(f"Size={size_bytes}") +print(f"Sha256={sha256}") +print(f"Manifest=https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}") +PY +``` + +Verify publication from the Pi: + +```bash +curl -sS "http://127.0.0.1:5105/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL" +``` + +Verify publication from outside the Pi: + +```bash +curl -sS "https://argus.kusoft.xyz/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL" +``` + +If you want Argus to apply its one-release retention rule immediately after manual publication, restart the container: + +```bash +docker restart argus +``` + +The restart is optional for making a newer semantic version visible. It is only needed to force startup pruning immediately. + +## Public API Contract For Update Clients + +Update clients must use the public read-only API. They must not call `/api/admin/*`; those routes are not part of this system. + +Check for an update: + +```text +GET https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel} +``` + +A `404` response means one of these states: + +- the app slug is not listed; +- there is no release for the requested `platform`; +- there is no release for the requested `channel`. + +A successful manifest response contains: + +```json +{ + "slug": "my-app", + "name": "My App", + "summary": "Short summary.", + "description": "Full description.", + "repositoryUrl": null, + "homepageUrl": null, + "release": { + "id": "release-guid", + "version": "1.2.3", + "channel": "stable", + "platform": "windows-x64", + "packageKind": "zip", + "downloadPath": "/api/apps/my-app/releases/release-guid/download", + "originalFileName": "my-app-1.2.3.zip", + "contentType": "application/zip", + "packageSizeBytes": 123456, + "sha256": "hex-encoded-sha256", + "publishedAt": "2026-06-13T18:00:00+00:00", + "notes": "What changed." + } +} +``` + +Download URL: + +```text +https://argus.kusoft.xyz{release.downloadPath} +``` + +There is also a latest-download endpoint: + +```text +GET https://argus.kusoft.xyz/api/apps/{slug}/download/latest?platform={platform}&channel={channel} +``` + +The manifest path is preferred for update clients because it provides `sha256`, `packageSizeBytes`, `version`, and release metadata before downloading. + +## Required Update Client Behavior + +Every app integrating with Argus must implement this flow: + +1. Store its current installed version locally. +2. Request the manifest for its `slug`, `platform`, and `channel`. +3. Treat HTTP `404` as "no update available for this app/platform/channel". +4. Compare local version to `release.version`. +5. If update is needed, download `baseUrl + release.downloadPath` to a temporary file. +6. Calculate SHA-256 of the downloaded file. +7. Compare the calculated SHA-256 to `release.sha256`. +8. Reject and delete the temporary file if SHA-256 does not match. +9. Apply the update with the app platform's own installer or replacement mechanism. +10. Store the new installed version only after a successful install or successful staged update. + +Argus does not install files on client devices. It only publishes metadata and serves package bytes. Installation logic belongs to each app. + +## Versioning Rule + +Use semantic versions when possible: + +```text +1.2.3 +1.2.4 +1.3.0 +2.0.0 +``` + +Argus latest selection is semantic-version aware. If an app uses a non-semantic version string, Argus falls back to case-insensitive string comparison for ordering. For predictable updates, keep release versions semantic. + +## Recommended Prompt For App Repositories + +When asking another app project to integrate with Argus, use this instruction: + +```text +Read ARGUS_PUBLICATION.md from the Argus repository. +Implement the "Required Update Client Behavior" section for this app. +Use: +- baseUrl: https://argus.kusoft.xyz +- slug: +- platform: +- channel: stable +Do not implement publishing over HTTP. Publishing is SSH-only and happens on the Argus server. +Always verify release.sha256 before installing or applying an update. +``` diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 0000000..8dac1e3 --- /dev/null +++ b/Dockerfile.server @@ -0,0 +1,14 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY QMax.slnx ./ +COPY server/QMax.Api/QMax.Api.csproj server/QMax.Api/ +RUN dotnet restore server/QMax.Api/QMax.Api.csproj +COPY server/QMax.Api server/QMax.Api +RUN dotnet publish server/QMax.Api/QMax.Api.csproj -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/publish . +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "QMax.Api.dll"] diff --git a/QMax.slnx b/QMax.slnx new file mode 100644 index 0000000..5fc92c4 --- /dev/null +++ b/QMax.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..8cd3281 --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +# 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`. + +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. +- `android` - Kotlin + Jetpack Compose Android client pointed at `https://qmax.kusoft.xyz`. +- `deploy` - Docker Compose + Caddy for Raspberry Pi 5. + +## Local Checks + +```powershell +dotnet test QMax.slnx +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 + +1. Point DNS `qmax.kusoft.xyz` to the Raspberry Pi public IP. +2. Install Docker Engine and the Docker Compose plugin on the Pi. +3. Copy the repository or the archive produced by `scripts/deploy-rpi.ps1`. +4. On the Pi: + +```bash +cd ~/qmax/deploy +cp .env.example .env +nano .env +docker compose up -d --build +``` + +This compose publishes QMAX API on `127.0.0.1:18080`. The existing system Caddy should contain: + +```caddy +qmax.kusoft.xyz { + encode zstd gzip + reverse_proxy 127.0.0.1:18080 +} +``` + +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. + +Secrets must stay in `.env`, not in git. + +## MAX Login + +Open: + +```text +https://qmax.kusoft.xyz/admin/max?pairingCode=YOUR_PAIRING_CODE +``` + +Use **Start phone login**. If MAX shows a robot check, solve it manually on this page using the screenshot plus click/type forms. When MAX sends the confirmation code, submit it on the same page or from the Android app MAX panel. + +The worker stores browser state in the `qmax-max-profile` Docker volume, so the MAX session should survive restarts. + +## Android Pairing + +Build or install: + +```powershell +cd android +.\gradlew.bat :app:assembleRelease --console=plain --no-daemon +``` + +On first launch: + +- Server: `https://qmax.kusoft.xyz` +- Code: `QMAX_PAIRING_CODE` from `.env` + +The first screen after pairing is the chat list. + +## APK Update Catalog + +Create a release package: + +```powershell +.\scripts\package-android-release.ps1 -Version 0.1.4 -VersionCode 5 +``` + +The compose file mounts `deploy/releases` as `/data/releases`, so files generated by the packaging script are served automatically after `docker compose up -d`. The server exposes: + +```text +GET /api/app-updates/android/latest +GET /api/app-updates/android/download/{fileName} +``` + +Use one stable release key for production APKs. Do not install a new APK signed by a different key over an already installed QMAX app. + +## Push Notifications + +The server has an FCM HTTP v1 dispatcher. To enable real push delivery on the Raspberry Pi, put a Firebase service account JSON under `deploy/secrets/` and set: + +```dotenv +QMAX_FIREBASE_PROJECT_ID=qmax-29df6 +QMAX_FIREBASE_SERVICE_ACCOUNT_PATH=/run/secrets/qmax/firebase-service-account.json +``` + +The Android app registers its FCM token after pairing. It uses the Google Services Gradle plugin with `android/app/google-services.json` for the package `xyz.kusoft.qmax`. `FirebaseBootstrap` still supports asset-based `firebase.android.json` or `google-services.json` as a fallback for custom local builds. + +## Argus Publication + +QMAX is published in Argus with: + +- base URL: `https://argus.kusoft.xyz` +- slug: `qmax` +- platform: `android` +- channel: `stable` + +Manifest: + +```text +https://argus.kusoft.xyz/api/apps/qmax/manifest?platform=android&channel=stable +``` + +Latest APK: + +```text +https://argus.kusoft.xyz/api/apps/qmax/download/latest?platform=android&channel=stable +``` + +The Android app checks this manifest, compares semantic versions, downloads the APK to a temporary file, verifies SHA-256, and only then opens Android's package installer. + +## Current MAX Mapping Status + +The deployed worker is authorized in MAX Web 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; +- 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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..5131c97 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,82 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") + id("com.google.gms.google-services") +} + +val signingProperties = Properties().apply { + val file = rootProject.file("key.properties") + if (file.exists()) { + file.inputStream().use(::load) + } +} + +android { + namespace = "xyz.kusoft.qmax" + compileSdk = 36 + + defaultConfig { + applicationId = "xyz.kusoft.qmax" + minSdk = 26 + targetSdk = 36 + versionCode = 47 + versionName = "0.1.46" + + buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"") + buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"") + } + + buildFeatures { + compose = true + buildConfig = true + } + + signingConfigs { + create("release") { + val storeFilePath = signingProperties.getProperty("storeFile") + if (!storeFilePath.isNullOrBlank()) { + storeFile = rootProject.file(storeFilePath) + storePassword = signingProperties.getProperty("storePassword") + keyAlias = signingProperties.getProperty("keyAlias") + keyPassword = signingProperties.getProperty("keyPassword") + } + } + } + + buildTypes { + release { + isMinifyEnabled = false + signingConfig = if (signingProperties.getProperty("storeFile").isNullOrBlank()) { + signingConfigs.getByName("debug") + } else { + signingConfigs.getByName("release") + } + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.18.0") + implementation("androidx.activity:activity-compose:1.11.0") + implementation("androidx.compose.ui:ui:1.10.5") + implementation("androidx.compose.ui:ui-tooling-preview:1.10.5") + implementation("androidx.compose.material3:material3:1.4.0") + implementation("androidx.compose.material:material-icons-extended:1.7.8") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.4") + implementation("androidx.navigation:navigation-compose:2.9.1") + implementation("androidx.media3:media3-exoplayer:1.8.0") + implementation("androidx.media3:media3-ui:1.8.0") + implementation("androidx.datastore:datastore-preferences:1.1.7") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("com.google.code.gson:gson:2.11.0") + implementation("io.coil-kt:coil-compose:2.7.0") + implementation("com.microsoft.signalr:signalr:8.0.17") + implementation("com.google.firebase:firebase-messaging:25.0.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0") + debugImplementation("androidx.compose.ui:ui-tooling:1.10.5") +} diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..7db5da4 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,32 @@ +{ + "project_info": { + "project_number": "471457572036", + "project_id": "qmax-29df6", + "storage_bucket": "qmax-29df6.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:471457572036:android:249a779d7a39b9c9359591", + "android_client_info": { + "package_name": "xyz.kusoft.qmax" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBYwLKxYG6nxBG6P11XHE-A4r6Pzz2umUk" + }, + { + "current_key": "AIzaSyB5QNIY1gBFdz92D_HFxX3wWMCn0HJSUc8" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a85f5da --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt new file mode 100644 index 0000000..a64ed4b --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -0,0 +1,3363 @@ +package xyz.kusoft.qmax + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.media.MediaRecorder +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.OpenableColumns +import android.view.WindowManager +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.Forward +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Chat +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material3.Button +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.ui.PlayerView +import coil.compose.AsyncImage +import coil.request.ImageRequest +import java.io.File +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +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.MessageDto +import xyz.kusoft.qmax.core.model.QMaxSession +import xyz.kusoft.qmax.ui.QMaxUiState +import xyz.kusoft.qmax.ui.QMaxViewModel +import xyz.kusoft.qmax.ui.theme.QMaxBackground +import xyz.kusoft.qmax.ui.theme.QMaxBlue +import xyz.kusoft.qmax.ui.theme.QMaxIncoming +import xyz.kusoft.qmax.ui.theme.QMaxMuted +import xyz.kusoft.qmax.ui.theme.QMaxOutgoing +import xyz.kusoft.qmax.ui.theme.QMaxText +import xyz.kusoft.qmax.ui.theme.QMaxTheme +import xyz.kusoft.qmax.ui.theme.QMaxUnread + +private const val MaxPendingAttachments = 10 +private val QuickReactions = listOf("\u2764\ufe0f", "\ud83d\udc4d", "\ud83d\ude02", "\ud83d\ude2e", "\ud83d\ude22", "\ud83d\ude21") + +class MainActivity : ComponentActivity() { + private val pendingOpenChatId = mutableStateOf(null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + pendingOpenChatId.value = intent.chatIdExtra() + val container = (application as QMaxApplication).container + setContent { + QMaxTheme { + val vm: QMaxViewModel = viewModel(factory = QMaxViewModel.Factory(container.repository)) + val chatId = pendingOpenChatId.value + LaunchedEffect(chatId) { + if (!chatId.isNullOrBlank()) { + vm.openChatById(chatId) + pendingOpenChatId.value = null + } + } + QMaxApp(vm) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + pendingOpenChatId.value = intent.chatIdExtra() + } +} + +private fun Intent?.chatIdExtra(): String? = this?.getStringExtra("chatId")?.takeIf { it.isNotBlank() } + +@Composable +private fun QMaxApp(vm: QMaxViewModel) { + val state by vm.state + val context = LocalContext.current + var notificationPermissionRequested by rememberSaveable { mutableStateOf(false) } + val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + notificationPermissionRequested = true + } + + LaunchedEffect(state.session?.serverUrl, state.session?.userName) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + state.session != null && + !notificationPermissionRequested && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + Box(Modifier.fillMaxSize().background(QMaxBackground)) { + when { + state.session == null && state.pairingCode.isNotBlank() -> AutoLoginScreen( + loading = state.loading, + error = state.error, + onRetry = vm::login + ) + state.session == null -> LoginScreen(vm) + state.selectedChat != null -> ChatScreen(vm) + else -> ChatListScreen(vm) + } + + state.previewImageUrl?.let { url -> + ImagePreview( + url = url, + session = state.session, + position = state.previewImageIndex, + total = state.previewImageGallery.size, + onPrevious = { vm.moveImagePreview(-1) }, + onNext = { vm.moveImagePreview(1) }, + onClose = vm::closeImage + ) + } + } +} + +@Composable +private fun AutoLoginScreen(loading: Boolean, error: String?, onRetry: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + .padding(28.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("QMAX", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = QMaxBlue) + Text("Подключение к qmax.kusoft.xyz", color = QMaxMuted, modifier = Modifier.padding(top = 6.dp)) + Spacer(Modifier.height(24.dp)) + if (loading || error == null) { + CircularProgressIndicator(color = QMaxBlue) + } + error?.let { + Spacer(Modifier.height(14.dp)) + Text(it, color = Color(0xFFC62828), textAlign = TextAlign.Center) + Spacer(Modifier.height(14.dp)) + Button(onClick = onRetry) { + Text("Повторить") + } + } + } +} + +@Composable +private fun LoginScreen(vm: QMaxViewModel) { + val state by vm.state + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + .padding(24.dp), + 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)) + Spacer(Modifier.height(28.dp)) + OutlinedTextField( + value = state.serverUrl, + onValueChange = vm::updateServerUrl, + label = { Text("Сервер") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = state.pairingCode, + onValueChange = vm::updatePairingCode, + label = { Text("Код подключения") }, + placeholder = { Text("qmax-...") }, + supportingText = { Text("Код выдаёт сервер QMAX для привязки телефона") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(18.dp)) + Button(onClick = vm::login, modifier = Modifier.fillMaxWidth()) { + Text("Войти") + } + ErrorLine(state.error) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChatListScreen(vm: QMaxViewModel) { + val state by vm.state + var menuOpen by remember { mutableStateOf(false) } + var searchMode by remember { mutableStateOf(false) } + var newChatOpen by remember { mutableStateOf(false) } + val filteredChats = remember(state.chats, state.searchQuery) { + val query = state.searchQuery.trim() + if (query.isBlank()) { + state.chats + } else { + state.chats.filter { + it.title.contains(query, ignoreCase = true) || + (it.lastMessagePreview?.contains(query, ignoreCase = true) == true) + } + } + } + + TelegramChatListContent( + state = state, + filteredChats = filteredChats, + menuOpen = menuOpen, + onMenuOpenChange = { menuOpen = it }, + onRefresh = vm::loadChats, + onCheckUpdates = vm::checkArgusUpdate, + onLogout = vm::logout, + onSearch = vm::updateSearchQuery, + onOpenChat = vm::openChat, + onNewChat = { newChatOpen = true } + ) + + if (newChatOpen) { + NewChatDialog( + vm = vm, + onDismiss = { newChatOpen = false }, + onCreate = { + newChatOpen = false + vm.createChat() + } + ) + } + return + + Scaffold( + topBar = { + TopAppBar( + title = { + if (searchMode) { + TextField( + value = state.searchQuery, + onValueChange = vm::updateSearchQuery, + placeholder = { Text("Поиск", color = Color(0xCCFFFFFF)) }, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) + } else { + val title = if (state.chatListConnectionIssue) { + "соединение..." + } else { + "QMAX" + } + Text(title, fontWeight = FontWeight.Bold) + } + }, + navigationIcon = { + if (searchMode) { + IconButton( + onClick = { + searchMode = false + vm.updateSearchQuery("") + } + ) { + Icon(Icons.Filled.Close, contentDescription = "Закрыть поиск") + } + } else { + IconButton(onClick = { menuOpen = true }) { + Icon(Icons.Filled.Menu, contentDescription = "Меню") + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = QMaxBlue, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + actionIconContentColor = Color.White + ), + actions = { + if (!searchMode) { + IconButton(onClick = { searchMode = true }) { + Icon(Icons.Filled.Search, contentDescription = "Поиск") + } + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Обновить чаты") }, + onClick = { + menuOpen = false + vm.loadChats() + } + ) + DropdownMenuItem( + text = { Text("Проверить обновления") }, + onClick = { + menuOpen = false + vm.checkArgusUpdate() + } + ) + DropdownMenuItem( + text = { Text("Выйти") }, + onClick = { + menuOpen = false + vm.logout() + } + ) + } + } + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { newChatOpen = true }, + containerColor = QMaxBlue, + contentColor = Color.White, + shape = CircleShape + ) { + Icon(Icons.Filled.Edit, contentDescription = "Новый чат") + } + }, + containerColor = Color.White + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize().background(Color.White)) { + ServiceBanner(vm) + state.updateMessage?.let { + Text( + it, + color = QMaxMuted, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp) + ) + } + ErrorLine(state.error) + if (filteredChats.isEmpty()) { + if (state.searchQuery.isNotBlank()) { + EmptyState("Ничего не найдено") + } else { + Spacer(Modifier.fillMaxSize()) + } + } else { + LazyColumn(Modifier.fillMaxSize()) { + items(filteredChats, key = { it.id }) { chat -> + ChatRow( + chat = chat, + session = state.session, + draftText = state.drafts[chat.id], + onClick = { vm.openChat(chat) } + ) + HorizontalDivider(color = Color(0xFFE9EEF2), thickness = 0.6.dp, modifier = Modifier.padding(start = 74.dp)) + } + } + } + } + } + + if (newChatOpen) { + NewChatDialog( + vm = vm, + onDismiss = { newChatOpen = false }, + onCreate = { + newChatOpen = false + vm.createChat() + } + ) + } +} + +@Composable +private fun TelegramChatListContent( + state: QMaxUiState, + filteredChats: List, + menuOpen: Boolean, + onMenuOpenChange: (Boolean) -> Unit, + onRefresh: () -> Unit, + onCheckUpdates: () -> Unit, + onLogout: () -> Unit, + onSearch: (String) -> Unit, + onOpenChat: (ChatDto) -> Unit, + onNewChat: () -> Unit +) { + Box(Modifier.fillMaxSize().background(Color.White)) { + Column( + Modifier + .fillMaxSize() + .statusBarsPadding() + .background(Color.White) + ) { + TelegramLikeHeader( + title = if (state.chatListConnectionIssue) "Соединение..." else "QMAX", + menuOpen = menuOpen, + onMenuOpenChange = onMenuOpenChange, + onRefresh = onRefresh, + onCheckUpdates = onCheckUpdates, + onLogout = onLogout + ) + ChatListSearchBar(query = state.searchQuery, onQuery = onSearch) + state.updateMessage?.let { + Text( + it, + color = QMaxMuted, + modifier = Modifier.fillMaxWidth().padding(horizontal = 28.dp, vertical = 6.dp) + ) + } + ErrorLine(state.error) + if (filteredChats.isEmpty()) { + if (state.searchQuery.isNotBlank()) { + EmptyState("Ничего не найдено") + } else { + Spacer(Modifier.fillMaxSize()) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 118.dp) + ) { + items(filteredChats, key = { it.id }) { chat -> + TelegramChatRow( + chat = chat, + session = state.session, + draftText = state.drafts[chat.id], + onClick = { onOpenChat(chat) } + ) + } + } + } + } + + ChatListFloatingActions( + modifier = Modifier + .align(Alignment.BottomEnd) + .navigationBarsPadding() + .padding(end = 22.dp, bottom = 96.dp), + onNewChat = onNewChat + ) + TelegramBottomNavigation( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(horizontal = 28.dp, vertical = 10.dp), + unreadCount = state.chats.sumOf { it.unreadCount } + ) + } +} + +@Composable +private fun TelegramLikeHeader( + title: String, + menuOpen: Boolean, + onMenuOpenChange: (Boolean) -> Unit, + onRefresh: () -> Unit, + onCheckUpdates: () -> Unit, + onLogout: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 26.dp, end = 18.dp, top = 14.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + QMaxHeaderAvatarStack() + Spacer(Modifier.width(14.dp)) + Text( + title, + color = if (title.startsWith("Соединение", ignoreCase = true)) Color(0xFF20BFA9) else QMaxText, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Box { + IconButton(onClick = { onMenuOpenChange(true) }, modifier = Modifier.size(48.dp)) { + Icon(Icons.Filled.MoreVert, contentDescription = "Меню", tint = QMaxText, modifier = Modifier.size(30.dp)) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { onMenuOpenChange(false) }) { + DropdownMenuItem( + text = { Text("Обновить чаты") }, + leadingIcon = { Icon(Icons.Filled.Refresh, contentDescription = null) }, + onClick = { + onMenuOpenChange(false) + onRefresh() + } + ) + DropdownMenuItem( + text = { Text("Проверить обновления") }, + onClick = { + onMenuOpenChange(false) + onCheckUpdates() + } + ) + DropdownMenuItem( + text = { Text("Выйти") }, + onClick = { + onMenuOpenChange(false) + onLogout() + } + ) + } + } + } +} + +@Composable +private fun QMaxHeaderAvatarStack() { + Box(Modifier.width(100.dp).height(54.dp)) { + Avatar(title = "Q", size = 50.dp) + Surface( + color = Color.White, + shape = CircleShape, + modifier = Modifier + .align(Alignment.Center) + .padding(start = 18.dp) + .size(54.dp) + ) { + Avatar(title = "M", size = 50.dp) + } + Surface( + color = Color.White, + shape = CircleShape, + modifier = Modifier + .align(Alignment.CenterEnd) + .size(54.dp) + ) { + Box( + Modifier + .padding(2.dp) + .clip(CircleShape) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Text("MAX", color = QMaxBlue, fontWeight = FontWeight.Black, style = MaterialTheme.typography.labelMedium) + } + } + } +} + +@Composable +private fun ChatListSearchBar(query: String, onQuery: (String) -> Unit) { + Surface( + color = Color(0xFFF1F1F3), + shape = RoundedCornerShape(30.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 28.dp, vertical = 6.dp) + .height(52.dp) + ) { + Row( + modifier = Modifier.fillMaxSize().padding(start = 18.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Filled.Search, contentDescription = null, tint = Color(0xFF6E747A), modifier = Modifier.size(28.dp)) + TextField( + value = query, + onValueChange = onQuery, + placeholder = { + Text("Поиск чатов", color = Color(0xFF8A8E93), style = MaterialTheme.typography.titleMedium) + }, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedTextColor = QMaxText, + unfocusedTextColor = QMaxText, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = Color(0xFF20BFA9) + ), + modifier = Modifier.weight(1f) + ) + if (query.isNotBlank()) { + IconButton(onClick = { onQuery("") }) { + Icon(Icons.Filled.Close, contentDescription = "Очистить поиск", tint = QMaxMuted) + } + } + } + } +} + +@Composable +private fun TelegramChatRow(chat: ChatDto, session: QMaxSession?, draftText: String?, onClick: () -> Unit) { + val hasDraft = !draftText.isNullOrBlank() + val preview = if (hasDraft) { + "Черновик: ${draftText.trim()}" + } else { + cleanChatPreview(chat.lastMessagePreview) ?: "Нет сообщений" + } + val isMediaPreview = !hasDraft && isGenericMediaPreview(chat.lastMessagePreview) + val unreadText = chat.unreadCount.coerceAtMost(999).toString() + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 26.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Avatar(title = chat.title, avatarUrl = chat.avatarUrl, session = session, size = 58.dp) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + chat.title, + fontWeight = FontWeight.Bold, + color = QMaxText, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + if (chat.isPinned) { + Icon(Icons.Filled.PushPin, contentDescription = null, tint = Color(0xFFB6BFC4), modifier = Modifier.size(16.dp)) + } + if (chat.isMuted) { + Spacer(Modifier.width(4.dp)) + Icon(Icons.Filled.NotificationsOff, contentDescription = null, tint = Color(0xFFB6BFC4), modifier = Modifier.size(18.dp)) + } + } + Spacer(Modifier.height(4.dp)) + Text( + preview, + color = when { + hasDraft -> Color(0xFFD84343) + isMediaPreview -> Color(0xFF20BFA9) + else -> Color(0xFF7B8085) + }, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(Modifier.width(8.dp)) + Column(horizontalAlignment = Alignment.End, modifier = Modifier.widthIn(min = 52.dp)) { + Text( + formatChatTime(chat.lastMessageAt), + color = Color(0xFF8A8E93), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + textAlign = TextAlign.End + ) + Spacer(Modifier.height(8.dp)) + if (chat.unreadCount > 0) { + Surface(color = QMaxBlue, shape = RoundedCornerShape(18.dp)) { + Text( + unreadText, + color = Color.White, + modifier = Modifier.widthIn(min = 34.dp).padding(horizontal = 8.dp, vertical = 2.dp), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold + ) + } + } + } + } +} + +@Composable +private fun ChatListFloatingActions(modifier: Modifier = Modifier, onNewChat: () -> Unit) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Surface(color = Color.White, shape = CircleShape, shadowElevation = 4.dp) { + IconButton(onClick = onNewChat, modifier = Modifier.size(52.dp)) { + Icon(Icons.Filled.PhotoCamera, contentDescription = "Камера", tint = QMaxText, modifier = Modifier.size(28.dp)) + } + } + Spacer(Modifier.height(12.dp)) + FloatingActionButton( + onClick = onNewChat, + containerColor = Color(0xFF20BFA9), + contentColor = Color.White, + shape = CircleShape, + modifier = Modifier.size(64.dp) + ) { + Icon(Icons.Filled.Add, contentDescription = "Новый чат", modifier = Modifier.size(34.dp)) + } + } +} + +@Composable +private fun TelegramBottomNavigation(modifier: Modifier = Modifier, unreadCount: Int) { + Surface( + color = Color.White, + shape = RoundedCornerShape(30.dp), + shadowElevation = 8.dp, + modifier = modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TelegramBottomNavItem( + label = "Чаты", + selected = true, + icon = { Icon(Icons.Filled.Chat, contentDescription = null) }, + badge = unreadCount.takeIf { it > 0 } + ) + TelegramBottomNavItem( + label = "Контакты", + selected = false, + icon = { Icon(Icons.Filled.Person, contentDescription = null) } + ) + TelegramBottomNavItem( + label = "Настройки", + selected = false, + icon = { Icon(Icons.Filled.Settings, contentDescription = null) } + ) + TelegramBottomNavItem( + label = "Профиль", + selected = false, + icon = { Icon(Icons.Filled.AccountCircle, contentDescription = null) } + ) + } + } +} + +@Composable +private fun RowScope.TelegramBottomNavItem( + label: String, + selected: Boolean, + icon: @Composable () -> Unit, + badge: Int? = null +) { + val tint = if (selected) Color(0xFF20BFA9) else QMaxText + Column( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(26.dp)) + .background(if (selected) Color(0xFFE6FAF5) else Color.Transparent) + .padding(vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box(contentAlignment = Alignment.TopEnd) { + Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) { + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.material3.LocalContentColor provides tint + ) { + icon() + } + } + badge?.let { + Surface(color = Color(0xFF20BFA9), shape = CircleShape, modifier = Modifier.padding(start = 18.dp)) { + Text( + it.coerceAtMost(99).toString(), + color = Color.White, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 1.dp) + ) + } + } + } + Text( + label, + color = tint, + style = MaterialTheme.typography.labelMedium, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun NewChatDialog(vm: QMaxViewModel, onDismiss: () -> Unit, onCreate: () -> Unit) { + val state by vm.state + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Новый чат") }, + text = { + Column { + OutlinedTextField( + value = state.newChatExternalId, + onValueChange = vm::updateNewChatExternalId, + label = { Text("ID или имя диалога MAX") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(10.dp)) + OutlinedTextField( + value = state.newChatTitle, + onValueChange = vm::updateNewChatTitle, + label = { Text("Название") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton(onClick = onCreate) { + Text("Создать") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Отмена") + } + } + ) +} + +@Composable +private fun SearchField(value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier) { + TextField( + value = value, + onValueChange = onValueChange, + placeholder = { Text("Поиск", color = QMaxMuted) }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null, tint = QMaxMuted) }, + singleLine = true, + shape = RoundedCornerShape(22.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color(0xFFF1F4F7), + unfocusedContainerColor = Color(0xFFF1F4F7), + disabledContainerColor = Color(0xFFF1F4F7), + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + modifier = modifier.fillMaxWidth() + ) +} + +@Composable +private fun ServiceBanner(vm: QMaxViewModel) { + val state by vm.state + val status = state.maxStatus + if (status?.isAuthorized != false) { + return + } + + Surface(color = Color(0xFFEFF6FF), tonalElevation = 0.dp) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)) { + Text("MAX требует входа", fontWeight = FontWeight.SemiBold, color = QMaxText) + Text(status.loginStage ?: status.status, color = QMaxMuted, style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = state.maxCode, + onValueChange = vm::updateMaxCode, + label = { Text("Код MAX") }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(8.dp)) + Button(onClick = vm::submitMaxCode) { Text("OK") } + } + TextButton(onClick = vm::startMaxLogin) { Text("Начать вход по телефону") } + } + } +} + +@Composable +private fun ChatRow(chat: ChatDto, session: QMaxSession?, draftText: String?, onClick: () -> Unit) { + val hasDraft = !draftText.isNullOrBlank() + val preview = if (hasDraft) { + "Черновик: ${draftText.trim()}" + } else { + cleanChatPreview(chat.lastMessagePreview) ?: "Нет сообщений" + } + val isMediaPreview = !hasDraft && isGenericMediaPreview(chat.lastMessagePreview) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Avatar(title = chat.title, avatarUrl = chat.avatarUrl, session = session, size = 52.dp) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + chat.title, + fontWeight = FontWeight.SemiBold, + color = QMaxText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + if (chat.isPinned) { + Icon( + Icons.Filled.PushPin, + contentDescription = null, + tint = QMaxMuted, + modifier = Modifier.padding(start = 4.dp).size(14.dp) + ) + } + if (chat.isMuted) { + Icon( + Icons.Filled.NotificationsOff, + contentDescription = null, + tint = QMaxMuted, + modifier = Modifier.padding(start = 4.dp).size(14.dp) + ) + } + Spacer(Modifier.width(6.dp)) + Text(formatChatTime(chat.lastMessageAt), color = QMaxMuted, style = MaterialTheme.typography.labelSmall) + } + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + preview, + color = when { + hasDraft -> Color(0xFFD84343) + isMediaPreview -> QMaxBlue + else -> QMaxMuted + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + if (chat.unreadCount > 0) { + Spacer(Modifier.width(8.dp)) + Surface(color = QMaxUnread, shape = CircleShape) { + Text( + chat.unreadCount.coerceAtMost(999).toString(), + color = Color.White, + modifier = Modifier.widthIn(min = 20.dp).padding(horizontal = 6.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} + +private fun cleanChatPreview(value: String?): String? { + return when { + isGenericMediaPreview(value) -> "\u041c\u0435\u0434\u0438\u0430" + isQuestionMarkArtifact(value) -> "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435" + else -> value + } +} + +private fun isGenericMediaPreview(value: String?): Boolean { + return when (value?.trim()?.lowercase()) { + "\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\u043d\u043d\u044b\u0435 \u0444\u043e\u0442\u043e", + "\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u0451\u043d\u043d\u044b\u0435 \u0444\u043e\u0442\u043e", + "attached photos" -> true + else -> false + } +} + +private fun isQuestionMarkArtifact(value: String?): Boolean { + val text = value?.trim() + if (text.isNullOrBlank()) return false + val questionMarks = text.count { it == '?' || it == '\uFFFD' } + if (questionMarks < 4) return false + val meaningfulCharacters = text.count { it.isLetterOrDigit() && it != '?' } + return meaningfulCharacters == 0 +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun ChatScreen(vm: QMaxViewModel) { + val state by vm.state + val chat = state.selectedChat ?: return + val session = state.session ?: return + val chatPresenceText = state.chatPresenceText + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val lifecycleOwner = LocalLifecycleOwner.current + var menuOpen by remember { mutableStateOf(false) } + var materialsOpen by remember(chat.id) { mutableStateOf(false) } + var chatSearchMode by remember(chat.id) { mutableStateOf(false) } + var chatSearchQuery by remember(chat.id) { mutableStateOf("") } + var activeSearchPosition by remember(chat.id) { mutableStateOf(0) } + var pendingAttachments by remember(chat.id) { mutableStateOf>(emptyList()) } + var scrollToLatestAfterSend by remember(chat.id) { mutableStateOf(false) } + val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> + if (uris.isNotEmpty()) { + pendingAttachments = (pendingAttachments + uris) + .distinctBy { it.toString() } + .take(MaxPendingAttachments) + } + } + val chatSearchTrimmedQuery = chatSearchQuery.trim() + val visibleMessages = remember(state.messages, state.chatSearchResults, chatSearchMode, chatSearchTrimmedQuery) { + if (chatSearchMode && chatSearchTrimmedQuery.isNotBlank()) { + (state.messages + state.chatSearchResults) + .distinctBy { it.id } + .sortedBy { it.sentAt } + } else { + state.messages + } + } + val timeline = remember(visibleMessages) { buildTimeline(visibleMessages) } + val latestMessage = remember(visibleMessages) { + visibleMessages.maxWithOrNull(messageChronologyComparator) + } + val messageById = remember(visibleMessages) { visibleMessages.associateBy { it.id } } + val timelineMessageIndexById = remember(timeline) { + timeline.mapIndexedNotNull { index, item -> + val message = (item as? TimelineItem.Message)?.message ?: return@mapIndexedNotNull null + message.id to index + }.toMap() + } + val imagePreviewSources = remember(visibleMessages, state.cachedImagePaths, session.serverUrl) { + visibleMessages.flatMap { message -> + message.attachments + .sortedBy { it.sortOrder } + .filter { it.isImageAttachment() } + .map { attachment -> + state.cachedImagePaths[attachment.id] ?: "${session.serverUrl.trimEnd('/')}${attachment.downloadPath}" + } + }.distinct() + } + val attachmentItems = remember(visibleMessages) { + visibleMessages.flatMap { message -> + message.attachments + .sortedBy { it.sortOrder } + .map { attachment -> ChatAttachmentItem(message, attachment) } + }.sortedByDescending { it.message.sentAt } + } + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + val serverSearchResultIds = remember(state.chatSearchResults, chatSearchTrimmedQuery) { + if (chatSearchTrimmedQuery.isBlank()) { + emptySet() + } else { + state.chatSearchResults.map { it.id }.toSet() + } + } + val searchResultIndexes = remember(timeline, chatSearchTrimmedQuery, serverSearchResultIds) { + if (chatSearchTrimmedQuery.isBlank()) { + emptyList() + } else { + timeline.mapIndexedNotNull { index, item -> + val message = (item as? TimelineItem.Message)?.message + index.takeIf { + message != null && + (message.id in serverSearchResultIds || message.matchesSearch(chatSearchTrimmedQuery)) + } + } + } + } + val activeSearchMessageId = remember(searchResultIndexes, activeSearchPosition, timeline) { + val activeTimelineIndex = searchResultIndexes.getOrNull(activeSearchPosition) + (activeTimelineIndex?.let(timeline::getOrNull) as? TimelineItem.Message)?.message?.id + } + val searchMatchIds = remember(searchResultIndexes, timeline) { + searchResultIndexes + .mapNotNull { (timeline.getOrNull(it) as? TimelineItem.Message)?.message?.id } + .toSet() + } + var previousTimelineSize by remember(chat.id) { mutableStateOf(0) } + var previousLatestMessageId by remember(chat.id) { mutableStateOf(null) } + val showScrollToBottom by remember { + derivedStateOf { + val layout = listState.layoutInfo + val total = layout.totalItemsCount + if (total == 0) { + false + } else { + val lastVisible = layout.visibleItemsInfo.lastOrNull()?.index ?: -1 + lastVisible < total - 2 + } + } + } + + LaunchedEffect(latestMessage?.id) { + val previousId = previousLatestMessageId + previousLatestMessageId = latestMessage?.id + if (latestMessage != null && + previousId != null && + previousId != latestMessage.id && + latestMessage.direction.equals("Incoming", ignoreCase = true) && + !chatSearchMode && + timeline.isNotEmpty() + ) { + listState.animateScrollToItem(timeline.lastIndex) + } + } + + LaunchedEffect(chatSearchQuery, searchResultIndexes.size) { + activeSearchPosition = if (chatSearchQuery.isNotBlank() && searchResultIndexes.isNotEmpty()) { + searchResultIndexes.lastIndex + } else { + 0 + } + } + + LaunchedEffect(chatSearchMode, chatSearchQuery, chat.id) { + vm.searchChatMessages(if (chatSearchMode) chatSearchQuery else "") + } + + LaunchedEffect(chatSearchMode, activeSearchPosition, searchResultIndexes) { + if (chatSearchMode) { + searchResultIndexes.getOrNull(activeSearchPosition)?.let { index -> + listState.animateScrollToItem(index) + } + } + } + + LaunchedEffect(timeline.size) { + if (timeline.isNotEmpty()) { + val shouldAutoScroll = previousTimelineSize == 0 || !showScrollToBottom + if (shouldAutoScroll) { + listState.animateScrollToItem(timeline.lastIndex) + } + previousTimelineSize = timeline.size + } + } + + LaunchedEffect(timeline.size, scrollToLatestAfterSend) { + if (scrollToLatestAfterSend && timeline.isNotEmpty()) { + listState.animateScrollToItem(timeline.lastIndex) + scrollToLatestAfterSend = false + } + } + + DisposableEffect(chat.id, lifecycleOwner) { + ForegroundChatTracker.setActiveChat(context, chat.id) + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START, + Lifecycle.Event.ON_RESUME -> ForegroundChatTracker.setActiveChat(context, chat.id) + Lifecycle.Event.ON_STOP, + Lifecycle.Event.ON_DESTROY -> ForegroundChatTracker.clearActiveChat(context, chat.id) + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + ForegroundChatTracker.clearActiveChat(context, chat.id) + } + } + + fun closeChatSearch() { + chatSearchMode = false + chatSearchQuery = "" + activeSearchPosition = 0 + } + + fun moveSearch(delta: Int) { + val total = searchResultIndexes.size + if (total == 0) return + activeSearchPosition = (activeSearchPosition + delta + total) % total + } + + BackHandler { + if (chatSearchMode) { + closeChatSearch() + } else { + vm.closeChat() + } + } + + val density = LocalDensity.current + val composerBottomPadding = with(density) { + maxOf( + WindowInsets.ime.getBottom(this).toDp(), + WindowInsets.navigationBars.getBottom(this).toDp() + ) + } + + Scaffold( + contentWindowInsets = WindowInsets( + left = 0.dp, + top = 0.dp, + right = 0.dp, + bottom = 0.dp + ), + topBar = { + TopAppBar( + title = { + if (chatSearchMode) { + TextField( + value = chatSearchQuery, + onValueChange = { chatSearchQuery = it }, + placeholder = { Text("Поиск в чате", color = Color(0xCCFFFFFF)) }, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Avatar(title = chat.title, avatarUrl = chat.avatarUrl, session = session, size = 38.dp) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text(chat.title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold) + if (chatPresenceText != null) { + Text( + chatPresenceText, + color = Color.White, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } else + Text("MAX подключен", style = MaterialTheme.typography.labelMedium) + } + } + } + }, + navigationIcon = { + if (chatSearchMode) { + IconButton(onClick = ::closeChatSearch) { + Icon(Icons.Filled.Close, contentDescription = "Закрыть поиск") + } + } else { + IconButton(onClick = vm::closeChat) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад") + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = QMaxBlue, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + actionIconContentColor = Color.White + ), + actions = { + if (chatSearchMode) { + if (state.chatSearchLoading && chatSearchQuery.isNotBlank()) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } + Text( + text = if (chatSearchQuery.isBlank()) { + "" + } else { + val current = if (searchResultIndexes.isEmpty()) 0 else activeSearchPosition.coerceIn(0, searchResultIndexes.lastIndex) + 1 + "$current/${searchResultIndexes.size}" + }, + color = Color.White, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 4.dp) + ) + IconButton(onClick = { moveSearch(-1) }, enabled = searchResultIndexes.isNotEmpty()) { + Icon(Icons.Filled.KeyboardArrowUp, contentDescription = "Предыдущее") + } + IconButton(onClick = { moveSearch(1) }, enabled = searchResultIndexes.isNotEmpty()) { + Icon(Icons.Filled.KeyboardArrowDown, contentDescription = "Следующее") + } + } else { + IconButton(onClick = { menuOpen = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = "Меню чата") + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Поиск") }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + onClick = { + menuOpen = false + chatSearchMode = true + } + ) + DropdownMenuItem( + text = { Text("Материалы") }, + leadingIcon = { Icon(Icons.Filled.Videocam, contentDescription = null) }, + onClick = { + menuOpen = false + materialsOpen = true + } + ) + DropdownMenuItem( + text = { Text("Обновить") }, + leadingIcon = { Icon(Icons.Filled.Refresh, contentDescription = null) }, + onClick = { + menuOpen = false + vm.loadMessages(showLoading = false) + } + ) + } + } + } + ) + }, + bottomBar = { + Box( + Modifier + .fillMaxWidth() + .padding(bottom = composerBottomPadding) + ) { + Composer( + text = state.composerText, + pendingAttachments = pendingAttachments, + replyTarget = state.replyTarget, + editTarget = state.editTarget, + isSending = state.sendingMessage, + onText = vm::updateComposer, + onSend = { + keyboardController?.hide() + focusManager.clearFocus(force = true) + vm.sendComposer(pendingAttachments) { + pendingAttachments = emptyList() + scrollToLatestAfterSend = true + } + }, + onRemoveAttachment = { uri -> + pendingAttachments = pendingAttachments.filterNot { it == uri } + }, + onCancelReply = vm::clearReplyTarget, + onCancelEdit = vm::clearEditTarget, + onAttach = { picker.launch("*/*") }, + onVoiceRecorded = vm::uploadVoice + ) + } + }, + containerColor = QMaxBackground + ) { padding -> + Box( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + ChatWallpaper() + when { + state.loading && timeline.isEmpty() -> LoadingCenter() + timeline.isEmpty() -> EmptyState("Сообщений пока нет") + else -> LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items(timeline, key = { it.key }) { item -> + when (item) { + is TimelineItem.Day -> DaySeparator(item.label) + is TimelineItem.Message -> MessageRow( + message = item.message, + session = session, + vm = vm, + imagePreviewSources = imagePreviewSources, + replyPreview = item.message.replyToMessageId?.let(messageById::get), + onReply = vm::replyTo, + onEdit = { message -> + pendingAttachments = emptyList() + vm.openEditMessage(message) + }, + onDelete = vm::deleteMessage, + onReaction = vm::toggleReaction, + onForward = vm::openForwardPicker, + onOpenReply = { reply -> + timelineMessageIndexById[reply.id]?.let { index -> + coroutineScope.launch { + listState.animateScrollToItem(index) + } + } + }, + isSearchMatch = item.message.id in searchMatchIds, + isActiveSearchMatch = item.message.id == activeSearchMessageId + ) + } + } + } + } + + state.error?.takeIf { it.isNotBlank() }?.let { error -> + Surface( + color = Color(0xFFFFEBEE), + shape = RoundedCornerShape(12.dp), + shadowElevation = 2.dp, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(12.dp) + ) { + Text( + text = error, + color = Color(0xFFC62828), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp) + ) + } + } + + if (showScrollToBottom && timeline.isNotEmpty()) { + FloatingActionButton( + onClick = { + coroutineScope.launch { + listState.animateScrollToItem(timeline.lastIndex) + } + }, + containerColor = Color.White, + contentColor = QMaxBlue, + shape = CircleShape, + modifier = Modifier + .align(Alignment.BottomEnd) + .navigationBarsPadding() + .padding(end = 14.dp, bottom = 14.dp) + .size(44.dp) + ) { + Icon(Icons.Filled.KeyboardArrowDown, contentDescription = "Вниз") + } + } + } + } + if (materialsOpen) { + ChatMaterialsDialog( + items = attachmentItems, + session = session, + cachedImagePaths = state.cachedImagePaths, + vm = vm, + onClose = { materialsOpen = false } + ) + } + if (state.forwardTarget != null) { + ForwardMessageDialog( + chats = state.chats, + selectedChatId = chat.id, + onSelect = vm::forwardTo, + onClose = vm::closeForwardPicker + ) + } +} + +@Composable +private fun ForwardMessageDialog( + chats: List, + selectedChatId: String?, + onSelect: (ChatDto) -> Unit, + onClose: () -> Unit +) { + val sortedChats = remember(chats) { + chats.sortedWith( + compareByDescending { it.isPinned } + .thenByDescending { it.lastMessageAt.orEmpty() } + ) + } + + AlertDialog( + onDismissRequest = onClose, + confirmButton = { + TextButton(onClick = onClose) { + Text("\u041e\u0442\u043c\u0435\u043d\u0430") + } + }, + title = { Text("\u041f\u0435\u0440\u0435\u0441\u043b\u0430\u0442\u044c") }, + text = { + if (sortedChats.isEmpty()) { + EmptyState("\u0427\u0430\u0442\u043e\u0432 \u043f\u043e\u043a\u0430 \u043d\u0435\u0442", modifier = Modifier.height(220.dp)) + } else { + LazyColumn(Modifier.fillMaxWidth().height(430.dp)) { + items(sortedChats, key = { it.id }) { chat -> + ForwardChatRow( + chat = chat, + isCurrent = chat.id == selectedChatId, + onClick = { onSelect(chat) } + ) + HorizontalDivider(color = Color(0xFFE9EEF2), thickness = 0.6.dp, modifier = Modifier.padding(start = 56.dp)) + } + } + } + } + ) +} + +@Composable +private fun ForwardChatRow(chat: ChatDto, isCurrent: Boolean, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 2.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Avatar(title = chat.title, size = 44.dp) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + chat.title, + color = QMaxText, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + if (isCurrent) { + Text( + "\u0422\u0435\u043a\u0443\u0449\u0438\u0439", + color = QMaxBlue, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(start = 6.dp) + ) + } + } + Spacer(Modifier.height(3.dp)) + Text( + cleanChatPreview(chat.lastMessagePreview) ?: "\u041d\u0435\u0442 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439", + color = QMaxMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(Modifier.width(8.dp)) + Text(formatChatTime(chat.lastMessageAt), color = QMaxMuted, style = MaterialTheme.typography.labelSmall) + } +} + +private data class ChatAttachmentItem(val message: MessageDto, val attachment: AttachmentDto) + +private enum class MaterialsTab(val title: String) { + Media("Медиа"), + Files("Файлы"), + Voice("Голос") +} + +@Composable +private fun ChatMaterialsDialog( + items: List, + session: QMaxSession, + cachedImagePaths: Map, + vm: QMaxViewModel, + onClose: () -> Unit +) { + var selectedTab by remember { mutableStateOf(MaterialsTab.Media) } + val mediaItems = remember(items) { items.filter { it.attachment.isVisualMediaAttachment() } } + val fileItems = remember(items) { + items.filterNot { + it.attachment.isVisualMediaAttachment() || + it.attachment.isVoiceAttachment() || + it.attachment.isAudioAttachment() + } + } + val voiceItems = remember(items) { + items.filter { it.attachment.isVoiceAttachment() || it.attachment.isAudioAttachment() } + } + val selectedItems = when (selectedTab) { + MaterialsTab.Media -> mediaItems + MaterialsTab.Files -> fileItems + MaterialsTab.Voice -> voiceItems + } + val imageGallery = remember(mediaItems, cachedImagePaths, session.serverUrl) { + mediaItems + .filter { it.attachment.isImageAttachment() } + .map { item -> + cachedImagePaths[item.attachment.id] + ?: "${session.serverUrl.trimEnd('/')}${item.attachment.downloadPath}" + } + .distinct() + } + + AlertDialog( + onDismissRequest = onClose, + confirmButton = { + TextButton(onClick = onClose) { + Text("Закрыть") + } + }, + title = { Text("Материалы") }, + text = { + Column(Modifier.fillMaxWidth().height(520.dp)) { + PrimaryTabRow(selectedTabIndex = selectedTab.ordinal) { + MaterialsTab.entries.forEach { tab -> + val count = when (tab) { + MaterialsTab.Media -> mediaItems.size + MaterialsTab.Files -> fileItems.size + MaterialsTab.Voice -> voiceItems.size + } + Tab( + selected = selectedTab == tab, + onClick = { selectedTab = tab }, + text = { Text("${tab.title} $count", maxLines = 1, overflow = TextOverflow.Ellipsis) } + ) + } + } + Spacer(Modifier.height(10.dp)) + if (selectedItems.isEmpty()) { + EmptyState("Пока пусто", modifier = Modifier.weight(1f)) + } else { + when (selectedTab) { + MaterialsTab.Media -> MaterialsMediaList( + items = mediaItems, + session = session, + cachedImagePaths = cachedImagePaths, + imageGallery = imageGallery, + vm = vm + ) + MaterialsTab.Files -> MaterialsFileList(items = fileItems, vm = vm) + MaterialsTab.Voice -> MaterialsVoiceList(items = voiceItems, session = session) + } + } + } + } + ) +} + +@Composable +private fun MaterialsMediaList( + items: List, + session: QMaxSession, + cachedImagePaths: Map, + imageGallery: List, + vm: QMaxViewModel +) { + LazyColumn(Modifier.fillMaxSize()) { + items(items.chunked(3)) { row -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(3.dp)) { + row.forEach { item -> + MaterialsMediaCell( + item = item, + session = session, + cachedImagePaths = cachedImagePaths, + imageGallery = imageGallery, + vm = vm, + modifier = Modifier.weight(1f).aspectRatio(1f) + ) + } + repeat(3 - row.size) { + Spacer(Modifier.weight(1f).aspectRatio(1f)) + } + } + Spacer(Modifier.height(3.dp)) + } + } +} + +@Composable +private fun MaterialsMediaCell( + item: ChatAttachmentItem, + session: QMaxSession, + cachedImagePaths: Map, + imageGallery: List, + vm: QMaxViewModel, + modifier: Modifier = Modifier +) { + val attachment = item.attachment + val url = "${session.serverUrl.trimEnd('/')}${attachment.downloadPath}" + val cachedImagePath = cachedImagePaths[attachment.id] + Box( + modifier = modifier + .background(Color(0xFFE1E8ED)) + .clickable { + if (attachment.isImageAttachment()) { + vm.openImage(cachedImagePath ?: url, imageGallery) + } + }, + contentAlignment = Alignment.Center + ) { + if (attachment.isImageAttachment()) { + LaunchedEffect(attachment.id, url) { + vm.cacheImage(attachment) + } + AsyncImage( + model = cachedImagePath?.let(::File) ?: imageRequest(url, session.accessToken), + contentDescription = attachment.fileName, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } else { + Icon(Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(42.dp)) + } + Text( + text = formatChatTime(item.message.sentAt), + color = Color.White, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier + .align(Alignment.BottomEnd) + .background(Color(0x99000000)) + .padding(horizontal = 5.dp, vertical = 2.dp) + ) + } +} + +@Composable +private fun MaterialsFileList(items: List, vm: QMaxViewModel) { + LazyColumn(Modifier.fillMaxSize()) { + items(items, key = { it.attachment.id }) { item -> + FileAttachment(item.attachment, vm) + HorizontalDivider(color = Color(0xFFE7EEF3)) + } + } +} + +@Composable +private fun MaterialsVoiceList(items: List, session: QMaxSession) { + LazyColumn(Modifier.fillMaxSize()) { + items(items, key = { it.attachment.id }) { item -> + val url = "${session.serverUrl.trimEnd('/')}${item.attachment.downloadPath}" + VoiceAttachment(item.attachment, url, session.accessToken) + HorizontalDivider(color = Color(0xFFE7EEF3)) + } + } +} + +@Composable +private fun ChatWallpaper() { + Canvas(Modifier.fillMaxSize().background(Color(0xFFE7EEF3))) { + val color = Color(0x1F6C7883) + val step = 86.dp.toPx() + val radius = 10.dp.toPx() + var y = -step + var row = 0 + while (y < size.height + step) { + var x = if (row % 2 == 0) -step / 2f else 0f + while (x < size.width + step) { + drawCircle(color = color, radius = radius, center = Offset(x + 22.dp.toPx(), y + 18.dp.toPx())) + drawLine( + color = color, + start = Offset(x + 48.dp.toPx(), y + 20.dp.toPx()), + end = Offset(x + 68.dp.toPx(), y + 34.dp.toPx()), + strokeWidth = 1.4.dp.toPx(), + cap = StrokeCap.Round + ) + drawLine( + color = color, + start = Offset(x + 20.dp.toPx(), y + 58.dp.toPx()), + end = Offset(x + 52.dp.toPx(), y + 58.dp.toPx()), + strokeWidth = 1.2.dp.toPx(), + cap = StrokeCap.Round + ) + x += step + } + y += step + row++ + } + } +} + +@Composable +private fun DaySeparator(label: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), horizontalArrangement = Arrangement.Center) { + Surface(color = Color(0xCCDDE6ED), shape = RoundedCornerShape(14.dp)) { + Text(label, color = QMaxMuted, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun MessageRow( + message: MessageDto, + session: QMaxSession, + vm: QMaxViewModel, + imagePreviewSources: List, + replyPreview: MessageDto?, + onReply: (MessageDto) -> Unit, + onEdit: (MessageDto) -> Unit, + onDelete: (MessageDto) -> Unit, + onReaction: (MessageDto, String) -> Unit, + onForward: (MessageDto) -> Unit, + onOpenReply: (MessageDto) -> Unit, + isSearchMatch: Boolean = false, + isActiveSearchMatch: Boolean = false +) { + var actionsOpen by remember(message.id) { mutableStateOf(false) } + var deleteConfirmOpen by remember(message.id) { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val outgoing = message.direction.equals("Outgoing", ignoreCase = true) + val sortedAttachments = message.attachments.sortedBy { it.sortOrder } + val visualAttachments = sortedAttachments.filter { it.isVisualMediaAttachment() } + val nonVisualAttachments = sortedAttachments.filterNot { it.isVisualMediaAttachment() } + val showAlbumGrid = visualAttachments.size > 1 && nonVisualAttachments.isEmpty() + val edgeToEdgeMedia = visualAttachments.isNotEmpty() + val shape = messageShape(outgoing) + val bubbleColor = when { + isActiveSearchMatch -> Color(0xFFFFF1A8) + isSearchMatch -> Color(0xFFFFF7D6) + outgoing -> QMaxOutgoing + else -> QMaxIncoming + } + var bubbleModifier = Modifier + .widthIn(min = 56.dp, max = 320.dp) + .clip(shape) + .background(bubbleColor) + .padding(if (edgeToEdgeMedia) 0.dp else 8.dp) + if (isActiveSearchMatch) { + bubbleModifier = bubbleModifier.border(2.dp, QMaxBlue, shape) + } + Box(Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 3.dp) + .combinedClickable( + onClick = {}, + onLongClick = { actionsOpen = true } + ), + horizontalArrangement = if (outgoing) Arrangement.End else Arrangement.Start + ) { + Column( + modifier = bubbleModifier + ) { + message.forwardedFrom?.takeIf { it.isNotBlank() }?.let { + ForwardedFromBlock( + name = it, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp) + ) + } + replyPreview?.let { + ReplyPreviewBlock( + message = it, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + onClick = { onOpenReply(it) } + ) + } + if (showAlbumGrid) { + MediaAlbumGrid( + attachments = visualAttachments, + session = session, + vm = vm, + imagePreviewSources = imagePreviewSources + ) + } else { + sortedAttachments.forEach { attachment -> + AttachmentView( + attachment = attachment, + session = session, + vm = vm, + imagePreviewSources = imagePreviewSources + ) + } + } + message.text?.takeIf { it.isNotBlank() }?.let { + Text(it, color = QMaxText, modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) + } + ReactionChips( + message = message, + onReaction = onReaction, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) + ) + Row( + Modifier + .align(Alignment.End) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (message.editedAt != null) { + Text( + "\u0438\u0437\u043c.", + color = QMaxMuted, + style = MaterialTheme.typography.labelSmall + ) + Spacer(Modifier.width(4.dp)) + } + Text(formatMessageTime(message.sentAt), color = QMaxMuted, style = MaterialTheme.typography.labelSmall) + if (outgoing) { + Spacer(Modifier.width(3.dp)) + MessageStateIcon(message.deliveryState) + } + } + } + } + DropdownMenu(expanded = actionsOpen, onDismissRequest = { actionsOpen = false }) { + ReactionPickerRow( + message = message, + onReaction = { selected -> + actionsOpen = false + onReaction(message, selected) + } + ) + DropdownMenuItem( + text = { Text("Ответить") }, + leadingIcon = { Icon(Icons.AutoMirrored.Filled.Reply, contentDescription = null) }, + onClick = { + actionsOpen = false + onReply(message) + } + ) + if (outgoing && !message.text.isNullOrBlank()) { + DropdownMenuItem( + text = { Text("\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c") }, + leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) }, + onClick = { + actionsOpen = false + onEdit(message) + } + ) + } + DropdownMenuItem( + text = { Text("\u041f\u0435\u0440\u0435\u0441\u043b\u0430\u0442\u044c") }, + leadingIcon = { Icon(Icons.AutoMirrored.Filled.Forward, contentDescription = null) }, + onClick = { + actionsOpen = false + onForward(message) + } + ) + DropdownMenuItem( + text = { Text("\u041f\u043e\u0434\u0435\u043b\u0438\u0442\u044c\u0441\u044f") }, + leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) }, + onClick = { + actionsOpen = false + vm.shareMessage(message) + } + ) + message.text?.takeIf { it.isNotBlank() }?.let { text -> + DropdownMenuItem( + text = { Text("Копировать") }, + leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) }, + onClick = { + actionsOpen = false + clipboard.setText(AnnotatedString(text)) + Toast.makeText(context, "Скопировано", Toast.LENGTH_SHORT).show() + } + ) + } + DropdownMenuItem( + text = { Text("\u0423\u0434\u0430\u043b\u0438\u0442\u044c", color = Color(0xFFD84343)) }, + leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null, tint = Color(0xFFD84343)) }, + onClick = { + actionsOpen = false + deleteConfirmOpen = true + } + ) + } + } + if (deleteConfirmOpen) { + AlertDialog( + onDismissRequest = { deleteConfirmOpen = false }, + title = { Text("\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435?") }, + text = { Text("\u041e\u043d\u043e \u0438\u0441\u0447\u0435\u0437\u043d\u0435\u0442 \u0438\u0437 QMAX \u043d\u0430 \u044d\u0442\u043e\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435.") }, + confirmButton = { + TextButton( + onClick = { + deleteConfirmOpen = false + onDelete(message) + } + ) { + Text("\u0423\u0434\u0430\u043b\u0438\u0442\u044c", color = Color(0xFFD84343)) + } + }, + dismissButton = { + TextButton(onClick = { deleteConfirmOpen = false }) { + Text("\u041e\u0442\u043c\u0435\u043d\u0430") + } + } + ) + } +} + +@Composable +private fun ReactionPickerRow(message: MessageDto, onReaction: (String) -> Unit) { + val selected = message.reactions.firstOrNull { it.reactedByMe }?.emoji + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + QuickReactions.forEach { emoji -> + Surface( + color = if (selected == emoji) Color(0xFFE7F1FA) else Color(0xFFF3F6F8), + shape = CircleShape, + modifier = Modifier + .size(38.dp) + .clickable { onReaction(emoji) } + ) { + Box(contentAlignment = Alignment.Center) { + Text(emoji, style = MaterialTheme.typography.titleMedium) + } + } + } + } + HorizontalDivider(color = Color(0xFFE7EEF3)) +} + +@Composable +private fun ReactionChips( + message: MessageDto, + onReaction: (MessageDto, String) -> Unit, + modifier: Modifier = Modifier +) { + if (message.reactions.isEmpty()) { + return + } + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + message.reactions.forEach { reaction -> + Surface( + color = if (reaction.reactedByMe) Color(0xFFDDF0FF) else Color(0xFFE9EEF2), + shape = RoundedCornerShape(14.dp), + modifier = Modifier.clickable { onReaction(message, reaction.emoji) } + ) { + Text( + text = "${reaction.emoji} ${reaction.count}", + color = if (reaction.reactedByMe) QMaxBlue else QMaxText, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp) + ) + } + } + } +} + +@Composable +private fun ForwardedFromBlock(name: String, modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + "\u041f\u0435\u0440\u0435\u0441\u043b\u0430\u043d\u043e \u043e\u0442", + color = QMaxMuted, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + name, + color = QMaxBlue, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun EditPreviewBlock(message: MessageDto, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFFE7F1FA)) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + Modifier + .width(3.dp) + .height(34.dp) + .background(QMaxBlue) + ) + Spacer(Modifier.width(8.dp)) + Column(Modifier.weight(1f)) { + Text( + "\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435", + color = QMaxBlue, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text(replyPreviewBody(message), color = QMaxMuted, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +private fun ReplyPreviewBlock(message: MessageDto, modifier: Modifier = Modifier, onClick: () -> Unit = {}) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFFE7F1FA)) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + Modifier + .width(3.dp) + .height(34.dp) + .background(QMaxBlue) + ) + Spacer(Modifier.width(8.dp)) + Column(Modifier.weight(1f)) { + Text(replyPreviewAuthor(message), color = QMaxBlue, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(replyPreviewBody(message), color = QMaxMuted, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +private fun replyPreviewAuthor(message: MessageDto): String { + return if (message.direction.equals("Outgoing", ignoreCase = true)) { + "You" + } else { + message.senderName?.takeIf { it.isNotBlank() } ?: "MAX" + } +} + +private fun replyPreviewBody(message: MessageDto): String { + message.text?.takeIf { it.isNotBlank() }?.let { return it } + val attachment = message.attachments.firstOrNull() ?: return "Message" + return when { + attachment.isImageAttachment() -> "Photo" + attachment.isVideoAttachment() -> "Video" + attachment.isVoiceAttachment() -> "Voice message" + attachment.isAudioAttachment() -> "Audio" + else -> attachment.fileName + } +} + +@Composable +private fun MediaAlbumGrid( + attachments: List, + session: QMaxSession, + vm: QMaxViewModel, + imagePreviewSources: List +) { + val visible = attachments.take(4) + val extraCount = (attachments.size - visible.size).coerceAtLeast(0) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + when (visible.size) { + 2 -> Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + visible.forEachIndexed { index, attachment -> + MediaAlbumCell( + attachment = attachment, + session = session, + vm = vm, + imagePreviewSources = imagePreviewSources, + extraCount = extraCount.takeIf { index == visible.lastIndex }, + modifier = Modifier.weight(1f).aspectRatio(1f) + ) + } + } + 3 -> { + MediaAlbumCell( + attachment = visible[0], + session = session, + vm = vm, + imagePreviewSources = imagePreviewSources, + extraCount = null, + modifier = Modifier.fillMaxWidth().aspectRatio(1.85f) + ) + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + visible.drop(1).forEachIndexed { index, attachment -> + MediaAlbumCell( + attachment = attachment, + session = session, + vm = vm, + imagePreviewSources = imagePreviewSources, + extraCount = extraCount.takeIf { index == 1 }, + modifier = Modifier.weight(1f).aspectRatio(1f) + ) + } + } + } + else -> { + visible.chunked(2).forEachIndexed { rowIndex, row -> + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + row.forEachIndexed { columnIndex, attachment -> + val isLastVisibleCell = rowIndex == 1 && columnIndex == row.lastIndex + MediaAlbumCell( + attachment = attachment, + session = session, + vm = vm, + imagePreviewSources = imagePreviewSources, + extraCount = extraCount.takeIf { isLastVisibleCell }, + modifier = Modifier.weight(1f).aspectRatio(1f) + ) + } + if (row.size == 1) { + Spacer(Modifier.weight(1f).aspectRatio(1f)) + } + } + } + } + } + } +} + +@Composable +private fun MediaAlbumCell( + attachment: AttachmentDto, + session: QMaxSession, + vm: QMaxViewModel, + imagePreviewSources: List, + extraCount: Int?, + modifier: Modifier = Modifier +) { + val url = rememberAttachmentUrl(session, attachment) + val state by vm.state + val cachedImagePath = state.cachedImagePaths[attachment.id] + + Box(modifier = modifier.background(Color(0xFFE1E8ED))) { + if (attachment.isImageAttachment()) { + LaunchedEffect(attachment.id, url) { + vm.cacheImage(attachment) + } + val model = cachedImagePath?.let(::File) ?: imageRequest(url, session.accessToken) + val previewSource = cachedImagePath ?: url + AsyncImage( + model = model, + contentDescription = attachment.fileName, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clickable { vm.openImage(previewSource, imagePreviewSources) } + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(44.dp)) + } + } + + if (extraCount != null && extraCount > 0) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0x99000000)), + contentAlignment = Alignment.Center + ) { + Text("+$extraCount", color = Color.White, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + } + } + } +} + +@Composable +private fun MessageStateIcon(state: String) { + val icon = if (state.equals("Read", true) || state.equals("Delivered", true)) { + Icons.Filled.DoneAll + } else { + Icons.Filled.Check + } + val tint = if (state.equals("Read", true)) QMaxBlue else QMaxMuted + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(14.dp)) +} + +@Composable +private fun AttachmentView( + attachment: AttachmentDto, + session: QMaxSession, + vm: QMaxViewModel, + imagePreviewSources: List +) { + val url = rememberAttachmentUrl(session, attachment) + val state by vm.state + val cachedImagePath = state.cachedImagePaths[attachment.id] + when { + attachment.isContactAttachment() -> ContactAttachment(attachment, vm) + attachment.isImageAttachment() -> ImageAttachment( + attachment, + url, + session.accessToken, + cachedImagePath, + vm, + imagePreviewSources + ) + attachment.isVideoAttachment() -> VideoAttachment(attachment, url, session.accessToken) + attachment.isVoiceAttachment() || attachment.isAudioAttachment() -> VoiceAttachment(attachment, url, session.accessToken) + else -> FileAttachment(attachment, vm) + } +} + +@Composable +private fun ImageAttachment( + attachment: AttachmentDto, + url: String, + token: String, + cachedImagePath: String?, + vm: QMaxViewModel, + imagePreviewSources: List +) { + LaunchedEffect(attachment.id, url) { + vm.cacheImage(attachment) + } + val model = cachedImagePath?.let(::File) ?: imageRequest(url, token) + val previewSource = cachedImagePath ?: url + AsyncImage( + model = model, + contentDescription = attachment.fileName, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1.25f) + .background(Color(0xFFE1E8ED)) + .clickable { vm.openImage(previewSource, imagePreviewSources) } + ) +} + +@Composable +private fun VideoAttachment(attachment: AttachmentDto, url: String, token: String) { + val player = rememberAuthorizedPlayer(url = url, token = token) + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .background(Color.Black) + ) { + AndroidView( + factory = { context -> + PlayerView(context).apply { + this.player = player + useController = true + setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING) + } + }, + update = { it.player = player }, + modifier = Modifier.fillMaxSize() + ) + Surface( + color = Color(0x99000000), + shape = RoundedCornerShape(bottomEnd = 10.dp), + modifier = Modifier.align(Alignment.TopStart) + ) { + Row(Modifier.padding(horizontal = 8.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Videocam, contentDescription = null, tint = Color.White, modifier = Modifier.size(15.dp)) + Spacer(Modifier.width(4.dp)) + Text( + text = attachment.fileName, + color = Color.White, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 220.dp) + ) + } + } + } +} + +@Composable +private fun VoiceAttachment(attachment: AttachmentDto, url: String, token: String) { + val player = rememberAuthorizedPlayer(url = url, token = token) + var isPlaying by remember { mutableStateOf(false) } + var position by remember { mutableStateOf(0L) } + var duration by remember { mutableStateOf(0L) } + + LaunchedEffect(player) { + while (true) { + isPlaying = player.isPlaying + position = player.currentPosition.coerceAtLeast(0L) + duration = player.duration.takeIf { it > 0L } ?: 0L + delay(250) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface(color = QMaxBlue, shape = CircleShape) { + IconButton( + onClick = { + if (player.isPlaying) { + player.pause() + } else { + if (player.playbackState == Player.STATE_ENDED) { + player.seekTo(0) + } + player.play() + } + }, + modifier = Modifier.size(42.dp) + ) { + Icon( + if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (isPlaying) "Пауза" else "Воспроизвести", + tint = Color.White + ) + } + } + Spacer(Modifier.width(8.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Mic, contentDescription = null, tint = QMaxMuted, modifier = Modifier.size(15.dp)) + Spacer(Modifier.width(4.dp)) + Text( + text = if (attachment.isVoiceAttachment()) "Голосовое сообщение" else attachment.fileName, + color = QMaxText, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Slider( + value = if (duration > 0L) position.toFloat() / duration.toFloat() else 0f, + onValueChange = { value -> + if (duration > 0L) { + player.seekTo((duration * value).toLong()) + } + }, + modifier = Modifier.height(28.dp) + ) + Text( + text = "${formatDuration(position)} / ${if (duration > 0L) formatDuration(duration) else "--:--"}", + color = QMaxMuted, + style = MaterialTheme.typography.labelSmall + ) + } + } +} + +@Composable +private fun ContactAttachment(attachment: AttachmentDto, vm: QMaxViewModel) { + val title = attachment.displayFileName() + .removeSuffix(".vcf") + .ifBlank { "\u041a\u043e\u043d\u0442\u0430\u043a\u0442" } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { vm.openAttachment(attachment) } + .padding(horizontal = 4.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface(color = Color(0xFFE9F3FD), shape = CircleShape) { + Icon( + Icons.Filled.Person, + contentDescription = null, + tint = QMaxBlue, + modifier = Modifier.padding(9.dp).size(22.dp) + ) + } + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + text = title, + color = QMaxText, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "\u041a\u043e\u043d\u0442\u0430\u043a\u0442 \u2022 \u041d\u0430\u0436\u043c\u0438\u0442\u0435, \u0447\u0442\u043e\u0431\u044b \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c", + color = QMaxMuted, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun FileAttachment(attachment: AttachmentDto, vm: QMaxViewModel) { + val title = attachment.displayFileName() + val subtitle = if (attachment.fileSizeBytes > 0L) { + "${formatFileSize(attachment.fileSizeBytes)} \u2022 \u041d\u0430\u0436\u043c\u0438\u0442\u0435, \u0447\u0442\u043e\u0431\u044b \u043e\u0442\u043a\u0440\u044b\u0442\u044c" + } else { + "\u041d\u0430\u0436\u043c\u0438\u0442\u0435, \u0447\u0442\u043e\u0431\u044b \u043e\u0442\u043a\u0440\u044b\u0442\u044c" + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { vm.openAttachment(attachment) } + .padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface(color = Color(0xFFE9F3FD), shape = CircleShape) { + Icon( + Icons.AutoMirrored.Filled.InsertDriveFile, + contentDescription = null, + tint = QMaxBlue, + modifier = Modifier.padding(9.dp).size(22.dp) + ) + } + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text(title, color = QMaxText, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(subtitle, color = QMaxMuted, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +private fun Composer( + text: String, + pendingAttachments: List, + replyTarget: MessageDto?, + editTarget: MessageDto?, + isSending: Boolean, + onText: (String) -> Unit, + onSend: () -> Unit, + onRemoveAttachment: (Uri) -> Unit, + onCancelReply: () -> Unit, + onCancelEdit: () -> Unit, + onAttach: () -> Unit, + onVoiceRecorded: (File) -> Unit +) { + val context = LocalContext.current + var isRecording by remember { mutableStateOf(false) } + var recordingSeconds by remember { mutableStateOf(0) } + var recorder by remember { mutableStateOf(null) } + var recordingFile by remember { mutableStateOf(null) } + var startAfterPermission by remember { mutableStateOf(false) } + + fun stopRecording(send: Boolean) { + val activeRecorder = recorder + val activeFile = recordingFile + recorder = null + recordingFile = null + isRecording = false + recordingSeconds = 0 + runCatching { activeRecorder?.stop() } + runCatching { activeRecorder?.release() } + if (send && activeFile != null && activeFile.length() > 0L) { + onVoiceRecorded(activeFile) + } else { + activeFile?.delete() + } + } + + fun startRecording() { + if (isRecording) return + val target = File(context.cacheDir, "qmax-voice-${System.currentTimeMillis()}.m4a") + val mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + @Suppress("DEPRECATION") + MediaRecorder() + } + runCatching { + mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC) + mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + mediaRecorder.setAudioEncodingBitRate(96_000) + mediaRecorder.setAudioSamplingRate(44_100) + mediaRecorder.setOutputFile(target.absolutePath) + mediaRecorder.prepare() + mediaRecorder.start() + recorder = mediaRecorder + recordingFile = target + recordingSeconds = 0 + isRecording = true + }.onFailure { + runCatching { mediaRecorder.release() } + target.delete() + } + } + + val recordPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted && startAfterPermission) { + startAfterPermission = false + startRecording() + } else { + startAfterPermission = false + } + } + + LaunchedEffect(isRecording) { + while (isRecording) { + delay(1000) + recordingSeconds += 1 + } + } + + DisposableEffect(Unit) { + onDispose { + stopRecording(send = false) + } + } + + Surface(color = Color.Transparent) { + if (isRecording) { + Row( + Modifier + .fillMaxWidth() + .background(Color.White) + .padding(horizontal = 10.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface(color = Color(0xFFE53935), shape = CircleShape, modifier = Modifier.size(10.dp)) {} + Spacer(Modifier.width(10.dp)) + Text( + "Запись ${formatDuration(recordingSeconds * 1000L)}", + color = QMaxText, + modifier = Modifier.weight(1f), + fontWeight = FontWeight.Medium + ) + TextButton(onClick = { stopRecording(send = false) }) { + Text("Отмена") + } + Surface(color = QMaxBlue, shape = CircleShape) { + IconButton(onClick = { stopRecording(send = true) }, modifier = Modifier.size(44.dp)) { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Отправить", tint = Color.White) + } + } + } + } else { + val isEditing = editTarget != null + val canSend = text.isNotBlank() || (!isEditing && pendingAttachments.isNotEmpty()) + Column( + Modifier + .fillMaxWidth() + .background(Color.Transparent) + ) { + editTarget?.let { + Row( + Modifier + .fillMaxWidth() + .background(Color.White) + .padding(start = 10.dp, top = 7.dp, end = 8.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + EditPreviewBlock( + message = it, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onCancelEdit, modifier = Modifier.size(38.dp)) { + Icon(Icons.Filled.Close, contentDescription = "\u041e\u0442\u043c\u0435\u043d\u0438\u0442\u044c \u0440\u0435\u0434\u0430\u043a\u0442\u0443\u0440\u0443", tint = QMaxMuted) + } + } + } ?: replyTarget?.let { + Row( + Modifier + .fillMaxWidth() + .background(Color.White) + .padding(start = 10.dp, top = 7.dp, end = 8.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ReplyPreviewBlock( + message = it, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onCancelReply, modifier = Modifier.size(38.dp)) { + Icon(Icons.Filled.Close, contentDescription = "Убрать ответ", tint = QMaxMuted) + } + } + } + if (!isEditing && pendingAttachments.isNotEmpty()) { + PendingAttachmentStrip( + attachments = pendingAttachments, + onRemove = onRemoveAttachment + ) + } + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 7.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + color = Color.White, + shape = RoundedCornerShape(24.dp), + tonalElevation = 1.dp, + shadowElevation = 1.dp, + modifier = Modifier.weight(1f) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (!isEditing) { + IconButton(onClick = onAttach) { + Icon(Icons.Filled.AttachFile, contentDescription = "Прикрепить", tint = QMaxMuted) + } + } + TextField( + value = text, + onValueChange = onText, + placeholder = { Text(if (pendingAttachments.isEmpty()) "Сообщение" else "Добавить подпись", color = QMaxMuted) }, + modifier = Modifier.weight(1f), + maxLines = 5, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ) + ) + } + } + Spacer(Modifier.width(7.dp)) + Surface(color = QMaxBlue, shape = CircleShape, shadowElevation = 1.dp) { + IconButton( + onClick = { + if (isSending) { + return@IconButton + } + if (isEditing) { + if (canSend) { + onSend() + } + } else if (!canSend) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + startRecording() + } else { + startAfterPermission = true + recordPermission.launch(Manifest.permission.RECORD_AUDIO) + } + } else { + onSend() + } + }, + enabled = !isSending, + modifier = Modifier.size(46.dp) + ) { + if (isSending) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Icon( + if (isEditing || canSend) Icons.AutoMirrored.Filled.Send else Icons.Filled.Mic, + contentDescription = if (canSend) "Отправить" else "Записать голосовое", + tint = Color.White + ) + } + } + } + } + } + } + } +} + +@Composable +private fun PendingAttachmentStrip( + attachments: List, + onRemove: (Uri) -> Unit +) { + Surface(color = Color.White, shadowElevation = 1.dp) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(attachments, key = { it.toString() }) { uri -> + PendingAttachmentChip(uri = uri, onRemove = onRemove) + } + } + } +} + +@Composable +private fun PendingAttachmentChip(uri: Uri, onRemove: (Uri) -> Unit) { + val preview = rememberPendingAttachmentPreview(uri) + val width = if (preview.isMedia) 72.dp else 148.dp + Box( + modifier = Modifier + .width(width) + .height(72.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFFE1E8ED)) + ) { + when { + preview.isImage -> AsyncImage( + model = uri, + contentDescription = preview.fileName, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + preview.isVideo -> Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF17212B)), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(34.dp)) + } + else -> Row( + modifier = Modifier + .fillMaxSize() + .padding(start = 10.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.AutoMirrored.Filled.InsertDriveFile, contentDescription = null, tint = QMaxBlue, modifier = Modifier.size(28.dp)) + Spacer(Modifier.width(8.dp)) + Text( + preview.fileName, + color = QMaxText, + style = MaterialTheme.typography.labelMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + if (preview.isMedia) { + Surface( + color = Color(0x99000000), + modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth() + ) { + Text( + preview.fileName, + color = Color.White, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp) + ) + } + } + Surface( + color = Color(0xCC000000), + shape = CircleShape, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + ) { + IconButton(onClick = { onRemove(uri) }, modifier = Modifier.size(24.dp)) { + Icon(Icons.Filled.Close, contentDescription = "Убрать вложение", tint = Color.White, modifier = Modifier.size(15.dp)) + } + } + } +} + +@Composable +private fun rememberPendingAttachmentPreview(uri: Uri): PendingAttachmentPreview { + val context = LocalContext.current + return remember(uri) { + val contentType = context.contentResolver.getType(uri).orEmpty() + var fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "attachment" + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (cursor.moveToFirst() && nameIndex >= 0) { + fileName = cursor.getString(nameIndex) ?: fileName + } + } + PendingAttachmentPreview( + fileName = fileName, + contentType = contentType, + isImage = contentType.startsWith("image/", ignoreCase = true), + isVideo = contentType.startsWith("video/", ignoreCase = true) + ) + } +} + +private data class PendingAttachmentPreview( + val fileName: String, + val contentType: String, + val isImage: Boolean, + val isVideo: Boolean +) { + val isMedia: Boolean get() = isImage || isVideo +} + +@Composable +private fun Avatar( + title: String, + size: androidx.compose.ui.unit.Dp, + avatarUrl: String? = null, + session: QMaxSession? = null +) { + val resolvedAvatarUrl = remember(avatarUrl, session?.serverUrl) { + avatarUrl?.takeIf { it.isNotBlank() }?.let { url -> + if (url.startsWith("http", ignoreCase = true)) { + url + } else { + session?.serverUrl?.trimEnd('/')?.let { serverUrl -> + "$serverUrl/${url.trimStart('/')}" + } + } + } + } + val avatarModel = if (resolvedAvatarUrl != null && + session != null && + resolvedAvatarUrl.startsWith(session.serverUrl.trimEnd('/'), ignoreCase = true) + ) { + imageRequest(resolvedAvatarUrl, session.accessToken) + } else { + resolvedAvatarUrl + } + + Box( + Modifier + .size(size) + .clip(CircleShape) + .background(avatarColor(title)), + contentAlignment = Alignment.Center + ) { + Text(title.take(1).uppercase(), color = Color.White, fontWeight = FontWeight.Bold) + if (avatarModel != null) { + AsyncImage( + model = avatarModel, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } +} + +private fun avatarColor(title: String): Color { + val colors = listOf( + QMaxBlue, + Color(0xFF00A884), + Color(0xFFF57C00), + Color(0xFFE53935), + Color(0xFF8E44AD), + Color(0xFF546E7A) + ) + return colors[(title.hashCode() and Int.MAX_VALUE) % colors.size] +} + +@Composable +private fun ImagePreview( + url: String, + session: QMaxSession?, + position: Int, + total: Int, + onPrevious: () -> Unit, + onNext: () -> Unit, + onClose: () -> Unit +) { + val model = when { + url.isLocalImagePath() -> File(url.removePrefix("file://")) + session != null -> imageRequest(url, session.accessToken) + else -> null + } + var scale by remember(url) { mutableStateOf(1f) } + var offset by remember(url) { mutableStateOf(Offset.Zero) } + val transformState = rememberTransformableState { zoomChange, panChange, _ -> + val nextScale = (scale * zoomChange).coerceIn(1f, 5f) + scale = nextScale + offset = if (nextScale == 1f) Offset.Zero else offset + panChange + } + + Box( + Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + if (model != null) { + AsyncImage( + model = model, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offset.x + translationY = offset.y + } + .transformable(transformState) + .pointerInput(url) { + detectTapGestures( + onDoubleTap = { + if (scale > 1f) { + scale = 1f + offset = Offset.Zero + } else { + scale = 2.5f + } + } + ) + } + ) + } + Surface( + color = Color(0x99000000), + shape = CircleShape, + modifier = Modifier + .align(Alignment.TopStart) + .padding(14.dp) + ) { + IconButton(onClick = onClose, modifier = Modifier.size(44.dp)) { + Icon(Icons.Filled.Close, contentDescription = "Закрыть", tint = Color.White) + } + } + if (total > 1) { + Surface( + color = Color(0x99000000), + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 18.dp) + ) { + Text( + text = "${position + 1}/$total", + color = Color.White, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + Surface( + color = Color(0x99000000), + shape = CircleShape, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 10.dp) + ) { + IconButton(onClick = onPrevious, modifier = Modifier.size(48.dp)) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous photo", tint = Color.White) + } + } + Surface( + color = Color(0x99000000), + shape = CircleShape, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 10.dp) + ) { + IconButton(onClick = onNext, modifier = Modifier.size(48.dp)) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next photo", tint = Color.White) + } + } + } + } +} + +@Composable +private fun LoadingCenter() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } +} + +@Composable +private fun EmptyState(text: String, modifier: Modifier = Modifier) { + Box(modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Text( + text = text, + color = QMaxMuted, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +private fun ErrorLine(error: String?) { + error?.let { + Spacer(Modifier.height(10.dp)) + Text(it, color = Color(0xFFC62828), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(horizontal = 16.dp)) + } +} + +private sealed interface TimelineItem { + val key: String + + data class Day(override val key: String, val label: String) : TimelineItem + data class Message(val message: MessageDto) : TimelineItem { + override val key: String = message.id + } +} + +private fun buildTimeline(messages: List): List { + val result = mutableListOf() + var previousDay: String? = null + messages.sortedWith(messageChronologyComparator).forEach { message -> + val day = dayKey(message.sentAt) + if (day != previousDay) { + result += TimelineItem.Day("day-$day", dayLabel(message.sentAt)) + previousDay = day + } + result += TimelineItem.Message(message) + } + return result +} + +private val messageChronologyComparator = compareBy { messageSortMillis(it.sentAt) } + .thenBy { it.id } + +private fun messageSortMillis(value: String): Long { + return parseDateTime(value)?.toInstant()?.toEpochMilli() ?: Long.MIN_VALUE +} + +private fun MessageDto.matchesSearch(query: String): Boolean { + val needle = query.trim().lowercase(Locale.ROOT) + if (needle.isBlank()) return false + return text?.lowercase(Locale.ROOT)?.contains(needle) == true || + senderName?.lowercase(Locale.ROOT)?.contains(needle) == true || + attachments.any { it.fileName.lowercase(Locale.ROOT).contains(needle) } +} + +private fun messageShape(outgoing: Boolean): RoundedCornerShape { + return if (outgoing) { + RoundedCornerShape(topStart = 16.dp, topEnd = 4.dp, bottomStart = 16.dp, bottomEnd = 16.dp) + } else { + RoundedCornerShape(topStart = 4.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp) + } +} + +private fun formatChatTime(value: String?): String { + val dateTime = parseDateTime(value) ?: return "" + val now = OffsetDateTime.now() + return when { + dateTime.toLocalDate() == now.toLocalDate() -> dateTime.format(DateTimeFormatter.ofPattern("HH:mm")) + dateTime.year == now.year -> dateTime.format(DateTimeFormatter.ofPattern("d MMM", Locale.forLanguageTag("ru"))) + else -> dateTime.format(DateTimeFormatter.ofPattern("dd.MM.yy")) + } +} + +private fun formatMessageTime(value: String): String { + return parseDateTime(value)?.format(DateTimeFormatter.ofPattern("HH:mm")).orEmpty() +} + +private fun dayKey(value: String): String { + return parseDateTime(value)?.toLocalDate()?.toString() ?: value.take(10) +} + +private fun dayLabel(value: String): String { + val date = parseDateTime(value)?.toLocalDate() ?: return value.take(10) + val today = OffsetDateTime.now().toLocalDate() + return when (date) { + today -> "Сегодня" + today.minusDays(1) -> "Вчера" + else -> date.format(DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru"))) + } +} + +private fun parseDateTime(value: String?): OffsetDateTime? { + if (value.isNullOrBlank()) return null + return runCatching { OffsetDateTime.parse(value) }.getOrElse { + runCatching { + OffsetDateTime.parse(value.replace(" ", "T")) + }.getOrNull() + }?.atZoneSameInstant(ZoneId.systemDefault())?.toOffsetDateTime() +} + +@Composable +private fun rememberAttachmentUrl(session: QMaxSession, attachment: AttachmentDto): String { + return "${session.serverUrl.trimEnd('/')}${attachment.downloadPath}" +} + +@Composable +private fun rememberAuthorizedPlayer(url: String, token: String): ExoPlayer { + val context = LocalContext.current + val player = remember(url, token) { + val httpFactory = DefaultHttpDataSource.Factory() + .setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token")) + ExoPlayer.Builder(context) + .setMediaSourceFactory(DefaultMediaSourceFactory(httpFactory)) + .build() + .apply { + setMediaItem(MediaItem.fromUri(url)) + prepare() + } + } + + DisposableEffect(player) { + onDispose { + player.release() + } + } + + return player +} + +@Composable +private fun imageRequest(url: String, token: String): ImageRequest { + val context = LocalContext.current + return ImageRequest.Builder(context) + .data(url) + .addHeader("Authorization", "Bearer $token") + .crossfade(true) + .build() +} + +private fun AttachmentDto.isImageAttachment(): Boolean { + return kind.equals("Image", true) || + kind.equals("Gif", true) || + contentType.startsWith("image/", ignoreCase = true) || + extension() in setOf("jpg", "jpeg", "png", "webp", "gif", "bmp", "heic", "heif") +} + +private fun AttachmentDto.isContactAttachment(): Boolean { + return kind.equals("Contact", true) || + contentType.contains("vcard", ignoreCase = true) || + extension() == "vcf" +} + +private fun AttachmentDto.isVideoAttachment(): Boolean { + return kind.equals("Video", true) || + contentType.startsWith("video/", ignoreCase = true) || + extension() in setOf("mp4", "mov", "webm", "mkv", "m4v", "3gp") +} + +private fun AttachmentDto.isVisualMediaAttachment(): Boolean { + return isImageAttachment() || isVideoAttachment() +} + +private fun AttachmentDto.isVoiceAttachment(): Boolean { + return kind.equals("VoiceNote", true) || + extension() in setOf("ogg", "opus") +} + +private fun AttachmentDto.isAudioAttachment(): Boolean { + return contentType.startsWith("audio/", ignoreCase = true) || + extension() in setOf("mp3", "m4a", "aac", "wav", "flac", "oga") +} + +private fun AttachmentDto.extension(): String { + return fileName.substringAfterLast('.', "").lowercase(Locale.ROOT) +} + +private fun AttachmentDto.displayFileName(): String { + val clean = fileName.trim() + if (clean.isBlank() || + clean.equals("download", ignoreCase = true) || + clean.equals("\u0441\u043a\u0430\u0447\u0430\u0442\u044c", ignoreCase = true) + ) { + return if (isContactAttachment()) "\u041a\u043e\u043d\u0442\u0430\u043a\u0442" else "\u0424\u0430\u0439\u043b" + } + + return clean +} + +private fun String.isLocalImagePath(): Boolean { + return startsWith("/") || startsWith("file://") +} + +private fun formatDuration(value: Long): String { + val totalSeconds = (value / 1000).coerceAtLeast(0) + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "%d:%02d".format(minutes, seconds) +} + +private fun formatFileSize(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + val kb = bytes / 1024.0 + if (kb < 1024) return "%.1f KB".format(Locale.US, kb) + val mb = kb / 1024.0 + if (mb < 1024) return "%.1f MB".format(Locale.US, mb) + return "%.1f GB".format(Locale.US, mb / 1024.0) +} diff --git a/android/app/src/main/java/xyz/kusoft/qmax/QMaxApplication.kt b/android/app/src/main/java/xyz/kusoft/qmax/QMaxApplication.kt new file mode 100644 index 0000000..d069e6d --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/QMaxApplication.kt @@ -0,0 +1,20 @@ +package xyz.kusoft.qmax + +import android.app.Application +import xyz.kusoft.qmax.core.QMaxContainer +import xyz.kusoft.qmax.core.push.FirebaseBootstrap +import xyz.kusoft.qmax.core.push.ForegroundChatTracker +import xyz.kusoft.qmax.core.push.PushNotifications + +class QMaxApplication : Application() { + lateinit var container: QMaxContainer + private set + + override fun onCreate() { + super.onCreate() + ForegroundChatTracker.clearActiveChat(this) + FirebaseBootstrap.ensureInitialized(this) + PushNotifications.ensureChannels(this) + container = QMaxContainer(this) + } +} diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxContainer.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxContainer.kt new file mode 100644 index 0000000..2f5c514 --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxContainer.kt @@ -0,0 +1,17 @@ +package xyz.kusoft.qmax.core + +import android.content.Context +import xyz.kusoft.qmax.core.local.AttachmentDiskCache +import xyz.kusoft.qmax.core.local.LocalMessageCache +import xyz.kusoft.qmax.core.network.QMaxApi +import xyz.kusoft.qmax.core.settings.MessageDraftStore +import xyz.kusoft.qmax.core.settings.TokenStore + +class QMaxContainer(context: Context) { + val tokenStore = TokenStore(context) + val draftStore = MessageDraftStore(context) + val messageCache = LocalMessageCache(context) + val attachmentCache = AttachmentDiskCache(context) + val api = QMaxApi() + val repository = QMaxRepository(context, api, tokenStore, draftStore, messageCache, attachmentCache) +} diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt new file mode 100644 index 0000000..5bdb393 --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt @@ -0,0 +1,501 @@ +package xyz.kusoft.qmax.core + +import android.content.Context +import android.content.Intent +import android.content.ClipData +import android.content.ActivityNotFoundException +import android.net.Uri +import android.provider.OpenableColumns +import androidx.core.content.FileProvider +import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okio.BufferedSink +import xyz.kusoft.qmax.BuildConfig +import xyz.kusoft.qmax.core.local.AttachmentDiskCache +import xyz.kusoft.qmax.core.local.LocalMessageCache +import xyz.kusoft.qmax.core.model.ArgusManifestDto +import xyz.kusoft.qmax.core.model.AttachmentDto +import xyz.kusoft.qmax.core.model.AuthResponse +import xyz.kusoft.qmax.core.model.ChatDto +import xyz.kusoft.qmax.core.model.ChatPresenceDto +import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto +import xyz.kusoft.qmax.core.model.MessageDto +import xyz.kusoft.qmax.core.model.QMaxSession +import xyz.kusoft.qmax.core.network.QMaxApi +import xyz.kusoft.qmax.core.network.QMaxHttpException +import xyz.kusoft.qmax.core.push.FirebaseBootstrap +import xyz.kusoft.qmax.core.settings.MessageDraftStore +import xyz.kusoft.qmax.core.settings.TokenStore +import java.io.File +import java.security.MessageDigest +import java.util.Locale + +class QMaxRepository( + private val context: Context, + private val api: QMaxApi, + private val tokenStore: TokenStore, + private val draftStore: MessageDraftStore, + private val messageCache: LocalMessageCache, + private val attachmentCache: AttachmentDiskCache +) { + val session: Flow = tokenStore.session + val drafts: Flow> = draftStore.drafts + private val refreshMutex = Mutex() + private var lastRefreshedSession: QMaxSession? = null + + suspend fun login(serverUrl: String, pairingCode: String): AuthResponse { + val response = api.login(serverUrl.ifBlank { BuildConfig.QMAX_DEFAULT_SERVER_URL }, pairingCode, android.os.Build.MODEL) + tokenStore.save( + QMaxSession( + serverUrl = serverUrl.ifBlank { BuildConfig.QMAX_DEFAULT_SERVER_URL }, + accessToken = response.accessToken, + refreshToken = response.refreshToken, + userName = response.user.displayName + ) + ) + return response + } + + suspend fun logout() { + tokenStore.clear() + draftStore.clearAll() + messageCache.clearAll() + attachmentCache.clearAll() + } + + suspend fun cachedChats(session: QMaxSession): List { + return messageCache.chats(session) + } + + suspend fun cacheChats(session: QMaxSession, chats: List) { + messageCache.saveChats(session, orderedChats(chats)) + } + + suspend fun chats(session: QMaxSession): List { + val chats = withFreshSession(session) { api.chats(it.serverUrl, it.accessToken) } + if (chats.isEmpty()) { + error("Chat list response is empty.") + } + messageCache.saveChats(session, chats) + return chats + } + + suspend fun createChat(session: QMaxSession, externalId: String, title: String): ChatDto { + val chat = withFreshSession(session) { api.createDirect(it.serverUrl, it.accessToken, externalId, title) } + val updatedChats = (cachedChats(session).filterNot { it.id == chat.id } + chat) + .let(::orderedChats) + messageCache.saveChats(session, updatedChats) + return chat + } + + suspend fun cachedMessages(session: QMaxSession, chatId: String): List { + return messageCache.messages(session, chatId) + } + + suspend fun messages(session: QMaxSession, chatId: String): List { + val messages = withFreshSession(session) { api.messages(it.serverUrl, it.accessToken, chatId) } + messageCache.saveMessages(session, chatId, messages) + return messages + } + + suspend fun markChatRead(session: QMaxSession, chatId: String) { + withFreshSession(session) { api.markChatRead(it.serverUrl, it.accessToken, chatId) } + val updatedChats = cachedChats(session).map { chat -> + if (chat.id == chatId) chat.copy(unreadCount = 0) else chat + } + messageCache.saveChats(session, orderedChats(updatedChats)) + } + + suspend fun searchMessages(session: QMaxSession, chatId: String, query: String): List { + val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) } + messages.forEach { messageCache.upsertMessage(session, it) } + return messages + } + + suspend fun chatPresence(session: QMaxSession, chatId: String): ChatPresenceDto { + return withFreshSession(session) { api.chatPresence(it.serverUrl, it.accessToken, chatId) } + } + + suspend fun sendMessage(session: QMaxSession, chatId: String, text: String, replyToMessageId: String?): MessageDto { + val message = withFreshSession(session) { + api.sendMessage(it.serverUrl, it.accessToken, chatId, text, replyToMessageId) + } + messageCache.upsertMessage(session, message) + return message + } + + suspend fun editMessage(session: QMaxSession, chatId: String, messageId: String, text: String): MessageDto { + val message = withFreshSession(session) { + api.editMessage(it.serverUrl, it.accessToken, chatId, messageId, text) + } + messageCache.upsertMessage(session, message) + return message + } + + suspend fun deleteMessage(session: QMaxSession, chatId: String, messageId: String) { + withFreshSession(session) { + api.deleteMessage(it.serverUrl, it.accessToken, chatId, messageId) + } + messageCache.deleteMessage(session, chatId, messageId) + } + + suspend fun deleteCachedMessage(session: QMaxSession, chatId: String, messageId: String) { + messageCache.deleteMessage(session, chatId, messageId) + } + + suspend fun setReaction(session: QMaxSession, chatId: String, messageId: String, emoji: String): MessageDto { + val message = withFreshSession(session) { + api.setReaction(it.serverUrl, it.accessToken, chatId, messageId, emoji) + } + messageCache.upsertMessage(session, message) + return message + } + + suspend fun clearReaction(session: QMaxSession, chatId: String, messageId: String): MessageDto { + val message = withFreshSession(session) { + api.clearReaction(it.serverUrl, it.accessToken, chatId, messageId) + } + messageCache.upsertMessage(session, message) + return message + } + + suspend fun forwardMessage( + session: QMaxSession, + sourceChatId: String, + messageId: String, + targetChatId: String + ): MessageDto { + val message = withFreshSession(session) { + api.forwardMessage(it.serverUrl, it.accessToken, sourceChatId, messageId, targetChatId) + } + messageCache.upsertMessage(session, message) + return message + } + + suspend fun upload(session: QMaxSession, chatId: String, uri: Uri, caption: String?, replyToMessageId: String?): MessageDto { + val meta = queryMeta(uri) + val body = contentUriRequestBody(uri, meta.contentType) + return withFreshSession(session) { + api.uploadAttachment( + it.serverUrl, + it.accessToken, + chatId, + meta.fileName, + meta.contentType, + body, + caption, + replyToMessageId + ) + }.also { messageCache.upsertMessage(session, it) } + } + + suspend fun uploadVoice(session: QMaxSession, chatId: String, file: File, replyToMessageId: String?): MessageDto { + val contentType = "audio/mp4" + val body = file.asRequestBody(contentType.toMediaTypeOrNull()) + return withFreshSession(session) { + api.uploadAttachment(it.serverUrl, it.accessToken, chatId, file.name, contentType, body, null, replyToMessageId) + }.also { messageCache.upsertMessage(session, it) } + } + + suspend fun cacheRealtimeMessage(session: QMaxSession, message: MessageDto) { + messageCache.upsertMessage(session, message) + } + + suspend fun cachedAttachmentFile(session: QMaxSession, attachment: AttachmentDto): File { + return withFreshSession(session) { freshSession -> + val url = absoluteUrl(freshSession, attachment.downloadPath) + attachmentCache.cachedFile(freshSession, attachment) { target -> + api.downloadToFile( + url = url, + target = target, + token = freshSession.accessToken, + expectedSize = attachment.fileSizeBytes + ) + } + } + } + + suspend fun openAttachment(session: QMaxSession, attachment: AttachmentDto): File { + val file = cachedAttachmentFile(session, attachment) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + val contentType = attachment.contentType.trim() + val fileName = attachment.fileName.lowercase(Locale.ROOT) + val mimeType = when { + contentType.contains("vcard", ignoreCase = true) || fileName.endsWith(".vcf") -> "text/x-vcard" + contentType.isNotBlank() -> contentType + else -> "application/octet-stream" + } + val intent = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, mimeType) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.clipData = ClipData.newUri(context.contentResolver, attachment.fileName, uri) + try { + context.startActivity(Intent.createChooser(intent, attachment.fileName).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } catch (_: ActivityNotFoundException) { + throw IllegalStateException("Нет приложения для открытия этого файла") + } + return file + } + + suspend fun shareMessage(session: QMaxSession, message: MessageDto) { + sharePayload( + session = session, + text = message.text?.takeIf { it.isNotBlank() }, + attachments = message.attachments.sortedBy { it.sortOrder } + ) + } + + suspend fun shareAttachment(session: QMaxSession, attachment: AttachmentDto) { + sharePayload(session = session, text = null, attachments = listOf(attachment)) + } + + suspend fun maxStatus(session: QMaxSession): MaxBridgeStatusDto { + return withFreshSession(session) { api.maxStatus(it.serverUrl, it.accessToken) } + } + + suspend fun startMaxLogin(session: QMaxSession, phone: String?): MaxBridgeStatusDto { + return withFreshSession(session) { api.startMaxLogin(it.serverUrl, it.accessToken, phone) } + } + + suspend fun submitMaxCode(session: QMaxSession, code: String): MaxBridgeStatusDto { + return withFreshSession(session) { api.submitMaxCode(it.serverUrl, it.accessToken, code) } + } + + suspend fun registerCurrentPushDevice(session: QMaxSession) { + if (!FirebaseBootstrap.ensureInitialized(context)) { + return + } + + val token = FirebaseMessaging.getInstance().token.await() + registerPushDevice(session, token) + } + + suspend fun registerPushDevice(session: QMaxSession, firebaseToken: String) { + if (firebaseToken.isBlank()) { + return + } + + withFreshSession(session) { + api.registerPushDevice(it.serverUrl, it.accessToken, firebaseToken) + } + } + + private suspend fun sharePayload(session: QMaxSession, text: String?, attachments: List) { + val files = attachments.map { attachment -> + SharedAttachment( + attachment = attachment, + file = cachedAttachmentFile(session, attachment), + mimeType = attachment.contentType.takeIf { it.isNotBlank() } ?: "application/octet-stream" + ) + } + val uris = files.map { + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", it.file) + } + + val intent = when { + files.isEmpty() -> Intent(Intent.ACTION_SEND) + .setType("text/plain") + .putExtra(Intent.EXTRA_TEXT, text.orEmpty()) + files.size == 1 -> Intent(Intent.ACTION_SEND) + .setType(files.single().mimeType) + .putExtra(Intent.EXTRA_STREAM, uris.single()) + else -> Intent(Intent.ACTION_SEND_MULTIPLE) + .setType(commonShareMime(files.map { it.mimeType })) + .putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) + } + + text?.takeIf { it.isNotBlank() }?.let { + intent.putExtra(Intent.EXTRA_TEXT, it) + } + + if (uris.isNotEmpty()) { + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.clipData = shareClipData(files, uris) + } + + try { + context.startActivity( + Intent.createChooser(intent, "Поделиться") + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + ) + } catch (_: ActivityNotFoundException) { + throw IllegalStateException("Нет приложения, чтобы поделиться этим содержимым") + } + } + + private fun shareClipData(files: List, uris: List): ClipData { + val first = files.first() + return ClipData.newUri(context.contentResolver, first.attachment.fileName, uris.first()).apply { + uris.drop(1).forEach { uri -> + addItem(ClipData.Item(uri)) + } + } + } + + private fun commonShareMime(types: List): String { + val normalized = types.map { it.takeIf(String::isNotBlank) ?: "application/octet-stream" } + if (normalized.isEmpty()) return "*/*" + if (normalized.distinct().size == 1) return normalized.first() + val families = normalized.map { it.substringBefore('/', missingDelimiterValue = "*") }.distinct() + return if (families.size == 1 && families.first() != "*") { + "${families.first()}/*" + } else { + "*/*" + } + } + + suspend fun refreshSession(session: QMaxSession): QMaxSession { + val response = api.refresh(session.serverUrl, session.refreshToken) + val refreshed = session.copy( + accessToken = response.accessToken, + refreshToken = response.refreshToken, + userName = response.user.displayName + ) + tokenStore.save(refreshed) + lastRefreshedSession = refreshed + return refreshed + } + + fun absoluteUrl(session: QMaxSession, path: String): String = api.absoluteUrl(session.serverUrl, path) + + private fun orderedChats(chats: List): List { + return chats.sortedWith( + compareByDescending { it.isPinned } + .thenByDescending { it.unreadCount > 0 } + .thenByDescending { it.lastMessageAt.orEmpty() } + .thenBy { it.title.lowercase() } + ) + } + + suspend fun draft(chatId: String): String = draftStore.draft(chatId) + + suspend fun saveDraft(chatId: String, text: String) { + draftStore.save(chatId, text) + } + + suspend fun clearDraft(chatId: String) { + draftStore.clear(chatId) + } + + suspend fun checkArgusUpdate(): ArgusManifestDto? { + val manifest = api.argusManifest() ?: return null + return if (isNewerVersion(manifest.release.version, BuildConfig.VERSION_NAME)) manifest else null + } + + suspend fun downloadAndOpenArgusUpdate(manifest: ArgusManifestDto): File { + val downloadUrl = "https://argus.kusoft.xyz${manifest.release.downloadPath}" + val target = File(context.cacheDir, "qmax-${manifest.release.version}.apk") + val size = api.downloadToFile(downloadUrl, target) + if (size != manifest.release.packageSizeBytes) { + target.delete() + error("APK size mismatch") + } + + val sha256 = sha256(target) + if (!sha256.equals(manifest.release.sha256, ignoreCase = true)) { + target.delete() + error("APK SHA-256 mismatch") + } + + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", target) + @Suppress("DEPRECATION") + val intent = Intent(Intent.ACTION_INSTALL_PACKAGE) + .setDataAndType(uri, "application/vnd.android.package-archive") + .putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + .putExtra(Intent.EXTRA_RETURN_RESULT, true) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + return target + } + + private fun queryMeta(uri: Uri): FileMeta { + val contentType = context.contentResolver.getType(uri) ?: "application/octet-stream" + var fileName = "upload" + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (cursor.moveToFirst() && nameIndex >= 0) { + fileName = cursor.getString(nameIndex) ?: fileName + } + } + return FileMeta(fileName, contentType) + } + + private fun contentUriRequestBody(uri: Uri, contentType: String): RequestBody { + return object : RequestBody() { + override fun contentType() = contentType.toMediaTypeOrNull() + + override fun writeTo(sink: BufferedSink) { + context.contentResolver.openInputStream(uri).use { input -> + requireNotNull(input) { "Cannot open selected file" } + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read == -1) break + sink.write(buffer, 0, read) + } + } + } + } + } + + private data class FileMeta(val fileName: String, val contentType: String) + + private suspend fun withFreshSession( + session: QMaxSession, + block: suspend (QMaxSession) -> T + ): T { + return try { + block(session) + } catch (error: QMaxHttpException) { + if (error.code != 401) { + throw error + } + val refreshed = refreshMutex.withLock { + lastRefreshedSession + ?.takeIf { it.serverUrl == session.serverUrl && it.accessToken != session.accessToken } + ?: refreshSession(session) + } + block(refreshed) + } + } + + private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read == -1) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private fun isNewerVersion(remote: String, local: String): Boolean { + val remoteParts = remote.split('.', '-', '_').map { it.toIntOrNull() ?: 0 } + val localParts = local.split('.', '-', '_').map { it.toIntOrNull() ?: 0 } + val max = maxOf(remoteParts.size, localParts.size) + for (index in 0 until max) { + val r = remoteParts.getOrElse(index) { 0 } + val l = localParts.getOrElse(index) { 0 } + if (r != l) return r > l + } + return false + } +} + +private data class SharedAttachment( + val attachment: AttachmentDto, + val file: File, + val mimeType: String +) diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/local/AttachmentDiskCache.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/local/AttachmentDiskCache.kt new file mode 100644 index 0000000..28da2ca --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/local/AttachmentDiskCache.kt @@ -0,0 +1,114 @@ +package xyz.kusoft.qmax.core.local + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import xyz.kusoft.qmax.core.model.AttachmentDto +import xyz.kusoft.qmax.core.model.QMaxSession +import java.io.File +import java.security.MessageDigest +import java.util.Locale + +class AttachmentDiskCache(context: Context) { + private val root = File(context.filesDir, "qmax-attachments") + + suspend fun cachedFile( + session: QMaxSession, + attachment: AttachmentDto, + downloader: suspend (File) -> Long + ): File { + return withContext(Dispatchers.IO) { + val target = targetFile(session, attachment) + if (isValid(target, attachment)) { + return@withContext target + } + + target.delete() + downloader(target) + + if (!isValid(target, attachment)) { + val expected = attachment.fileSizeBytes.takeIf { it > 0 }?.toString() ?: "non-empty" + val actual = if (target.exists()) target.length().toString() else "missing" + target.delete() + error("Attachment cache mismatch: expected $expected, got $actual") + } + + target + } + } + + suspend fun clearAll() { + withContext(Dispatchers.IO) { + root.deleteRecursively() + } + } + + private fun targetFile(session: QMaxSession, attachment: AttachmentDto): File { + val sessionDir = root.resolve(cacheKey(session)).also { it.mkdirs() } + val key = sha256( + listOf( + attachment.id, + attachment.downloadPath, + attachment.fileName, + attachment.contentType, + attachment.fileSizeBytes.toString() + ).joinToString("|") + ).take(40) + return sessionDir.resolve("$key.${extension(attachment)}") + } + + private fun isValid(file: File, attachment: AttachmentDto): Boolean { + if (!file.exists() || file.length() <= 0L) { + return false + } + + return attachment.fileSizeBytes <= 0L || file.length() == attachment.fileSizeBytes + } + + private fun cacheKey(session: QMaxSession): String { + return sha256("${session.serverUrl}|${session.userName}").take(24) + } + + private fun extension(attachment: AttachmentDto): String { + val fileExtension = attachment.fileName + .substringAfterLast('.', "") + .lowercase(Locale.ROOT) + .takeIf { it.length in 1..12 && it.all { char -> char.isLetterOrDigit() } } + + if (fileExtension != null) { + return fileExtension + } + + return when (attachment.contentType.substringBefore(';').lowercase(Locale.ROOT)) { + "image/jpeg" -> "jpg" + "image/png" -> "png" + "image/webp" -> "webp" + "image/gif" -> "gif" + "image/bmp" -> "bmp" + "image/heic" -> "heic" + "image/heif" -> "heif" + "video/mp4" -> "mp4" + "video/quicktime" -> "mov" + "video/webm" -> "webm" + "video/x-matroska" -> "mkv" + "audio/ogg" -> "ogg" + "audio/opus" -> "opus" + "audio/mp4" -> "m4a" + "audio/aac" -> "aac" + "audio/mpeg" -> "mp3" + "audio/wav" -> "wav" + "audio/flac" -> "flac" + "application/pdf" -> "pdf" + "application/zip" -> "zip" + "text/vcard" -> "vcf" + "text/x-vcard" -> "vcf" + "text/plain" -> "txt" + else -> "bin" + } + } + + private fun sha256(value: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) } + } +} diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/local/LocalMessageCache.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/local/LocalMessageCache.kt new file mode 100644 index 0000000..c68dea3 --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/local/LocalMessageCache.kt @@ -0,0 +1,176 @@ +package xyz.kusoft.qmax.core.local + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import xyz.kusoft.qmax.core.model.ChatDto +import xyz.kusoft.qmax.core.model.MessageDto +import xyz.kusoft.qmax.core.model.QMaxSession +import java.io.File +import java.security.MessageDigest + +class LocalMessageCache(context: Context) { + private val root = File(context.filesDir, "qmax-cache") + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + suspend fun chats(session: QMaxSession): List { + return withContext(Dispatchers.IO) { + readPayload(cacheDir(session).resolve("chats.json")) + ?.chats + .orEmpty() + .map(::cleanChat) + } + } + + suspend fun saveChats(session: QMaxSession, chats: List) { + withContext(Dispatchers.IO) { + writePayload(cacheDir(session).resolve("chats.json"), ChatCachePayload(chats = chats.map(::cleanChat))) + } + } + + suspend fun messages(session: QMaxSession, chatId: String): List { + return withContext(Dispatchers.IO) { + readPayload(messageFile(session, chatId)) + ?.messages + .orEmpty() + .filterNot(::isGenericMediaPlaceholder) + } + } + + suspend fun saveMessages(session: QMaxSession, chatId: String, messages: List) { + withContext(Dispatchers.IO) { + writePayload( + messageFile(session, chatId), + MessageCachePayload( + messages = messages + .filterNot(::isGenericMediaPlaceholder) + .sortedBy { it.sentAt } + .takeLast(MaxMessagesPerChat) + ) + ) + } + } + + suspend fun upsertMessage(session: QMaxSession, message: MessageDto) { + val current = messages(session, message.chatId) + val updated = (current.filterNot { it.id == message.id } + message) + .filterNot(::isGenericMediaPlaceholder) + .sortedBy { it.sentAt } + .takeLast(MaxMessagesPerChat) + saveMessages(session, message.chatId, updated) + } + + suspend fun deleteMessage(session: QMaxSession, chatId: String, messageId: String) { + val updated = messages(session, chatId).filterNot { it.id == messageId } + saveMessages(session, chatId, updated) + } + + suspend fun clearAll() { + withContext(Dispatchers.IO) { + root.deleteRecursively() + } + } + + private fun cacheDir(session: QMaxSession): File { + return root.resolve(cacheKey(session)).also { it.mkdirs() } + } + + private fun messageFile(session: QMaxSession, chatId: String): File { + return cacheDir(session).resolve("messages-${sha256(chatId).take(24)}.json") + } + + private inline fun readPayload(file: File): T? { + if (!file.exists() || file.length() == 0L) { + return null + } + + return runCatching { + json.decodeFromString(file.readText()) + }.getOrNull() + } + + private inline fun writePayload(file: File, payload: T) { + file.parentFile?.mkdirs() + val part = File(file.parentFile, "${file.name}.part") + part.writeText(json.encodeToString(payload)) + if (file.exists()) { + file.delete() + } + part.renameTo(file) + } + + private fun cacheKey(session: QMaxSession): String { + return sha256("${session.serverUrl}|${session.userName}").take(24) + } + + private fun sha256(value: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun cleanChat(chat: ChatDto): ChatDto { + return when { + isGenericMediaLabel(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u041c\u0435\u0434\u0438\u0430") + isQuestionMarkArtifact(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435") + else -> chat + } + } + + private fun isGenericMediaPlaceholder(message: MessageDto): Boolean { + return message.attachments.isEmpty() && isGenericMediaLabel(message.text) + } + + private fun isQuestionMarkArtifact(value: String?): Boolean { + val text = value?.trim() + if (text.isNullOrBlank()) return false + val questionMarks = text.count { it == '?' || it == '\uFFFD' } + if (questionMarks < 4) return false + val meaningfulCharacters = text.count { it.isLetterOrDigit() && it != '?' } + return meaningfulCharacters == 0 + } + + private fun isGenericMediaLabel(value: String?): Boolean { + return when (value?.trim()?.lowercase()) { + "\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\u043d\u043d\u044b\u0435 \u0444\u043e\u0442\u043e", + "\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u0451\u043d\u043d\u044b\u0435 \u0444\u043e\u0442\u043e", + "attached photos" -> true + else -> false + } + } + + @Serializable + private data class ChatCachePayload( + val version: Int = 1, + val chats: List + ) + + @Serializable + private data class MessageCachePayload( + val version: Int = 1, + val messages: List + ) + + private companion object { + const val MaxMessagesPerChat = 250 + } +} diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt new file mode 100644 index 0000000..9e2267e --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt @@ -0,0 +1,184 @@ +package xyz.kusoft.qmax.core.model + +import kotlinx.serialization.Serializable + +@Serializable +data class UserDto( + val id: String, + val displayName: String, + val phoneNumber: String? = null, + val avatarPath: String? = null +) + +@Serializable +data class AuthResponse( + val accessToken: String, + val refreshToken: String, + val expiresAt: String, + val user: UserDto +) + +@Serializable +data class DeviceLoginRequest( + val pairingCode: String, + val deviceName: String +) + +@Serializable +data class RefreshTokenRequest(val refreshToken: String) + +@Serializable +data class ChatDto( + val id: String, + val externalId: String? = null, + val kind: String, + val title: String, + val avatarUrl: String? = null, + val lastMessagePreview: String? = null, + val lastMessageAt: String? = null, + val unreadCount: Int = 0, + val isPinned: Boolean = false, + val isMuted: Boolean = false, + val isArchived: Boolean = false +) + +@Serializable +data class ChatPresenceDto( + val isTyping: Boolean = false, + val statusText: String? = null, + val updatedAt: String? = null +) + +@Serializable +data class MessageDto( + val id: String, + val chatId: String, + val externalId: String? = null, + val senderName: String? = null, + val direction: String, + val text: String? = null, + val sentAt: String, + val editedAt: String? = null, + val deletedAt: String? = null, + val replyToMessageId: String? = null, + val forwardedFrom: String? = null, + val deliveryState: String, + val error: String? = null, + val reactions: List = emptyList(), + val attachments: List = emptyList() +) + +@Serializable +data class ReactionDto( + val emoji: String, + val count: Int, + val reactedByMe: Boolean = false +) + +@Serializable +data class AttachmentDto( + val id: String, + val fileName: String, + val contentType: String, + val fileSizeBytes: Long, + val downloadPath: String, + val kind: String, + val sortOrder: Int +) + +@Serializable +data class SendMessageRequest( + val text: String, + val replyToMessageId: String? = null +) + +@Serializable +data class MarkChatReadRequest( + val read: Boolean = true +) + +@Serializable +data class EditMessageRequest( + val text: String +) + +@Serializable +data class ForwardMessageRequest( + val targetChatId: String +) + +@Serializable +data class SetReactionRequest( + val emoji: String +) + +@Serializable +data class MessageDeletedDto( + val chatId: String, + val messageId: String +) + +@Serializable +data class CreateDirectChatRequest( + val externalChatId: String, + val title: String, + val avatarUrl: String? = null +) + +@Serializable +data class MaxBridgeStatusDto( + val mode: String, + val isAuthorized: Boolean, + val loginStage: String? = null, + val status: String, + val url: String? = null, + val title: String? = null, + val lastError: String? = null, + val updatedAt: String +) + +@Serializable +data class BeginMaxLoginRequest(val phoneNumber: String? = null) + +@Serializable +data class SubmitMaxCodeRequest(val code: String) + +@Serializable +data class RegisterPushDeviceRequest( + val firebaseToken: String, + val platform: String = "android" +) + +data class QMaxSession( + val serverUrl: String, + val accessToken: String, + val refreshToken: String, + val userName: String +) + +@Serializable +data class ArgusManifestDto( + val slug: String, + val name: String, + val summary: String? = null, + val description: String? = null, + val repositoryUrl: String? = null, + val homepageUrl: String? = null, + val release: ArgusReleaseDto +) + +@Serializable +data class ArgusReleaseDto( + val id: String, + val version: String, + val channel: String, + val platform: String, + val packageKind: String, + val downloadPath: String, + val originalFileName: String, + val contentType: String, + val packageSizeBytes: Long, + val sha256: String, + val publishedAt: String, + val notes: String? = null +) diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt new file mode 100644 index 0000000..60d7963 --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt @@ -0,0 +1,418 @@ +package xyz.kusoft.qmax.core.network + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import xyz.kusoft.qmax.BuildConfig +import xyz.kusoft.qmax.core.model.AuthResponse +import xyz.kusoft.qmax.core.model.ArgusManifestDto +import xyz.kusoft.qmax.core.model.BeginMaxLoginRequest +import xyz.kusoft.qmax.core.model.ChatDto +import xyz.kusoft.qmax.core.model.ChatPresenceDto +import xyz.kusoft.qmax.core.model.CreateDirectChatRequest +import xyz.kusoft.qmax.core.model.DeviceLoginRequest +import xyz.kusoft.qmax.core.model.EditMessageRequest +import xyz.kusoft.qmax.core.model.ForwardMessageRequest +import xyz.kusoft.qmax.core.model.MarkChatReadRequest +import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto +import xyz.kusoft.qmax.core.model.MessageDto +import xyz.kusoft.qmax.core.model.RegisterPushDeviceRequest +import xyz.kusoft.qmax.core.model.SendMessageRequest +import xyz.kusoft.qmax.core.model.SetReactionRequest +import xyz.kusoft.qmax.core.model.SubmitMaxCodeRequest +import java.io.IOException +import java.io.File +import java.io.FileOutputStream +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class QMaxHttpException(val code: Int, message: String) : IOException(message) + +class QMaxApi { + private val client = OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .callTimeout(150, TimeUnit.SECONDS) + .build() + private val downloadClient = client.newBuilder() + .callTimeout(5, TimeUnit.MINUTES) + .build() + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + private val jsonMediaType = "application/json; charset=utf-8".toMediaType() + + suspend fun login(serverUrl: String, pairingCode: String, deviceName: String): AuthResponse { + return post(serverUrl, "/api/auth/device/login", null, DeviceLoginRequest(pairingCode, deviceName)) + } + + suspend fun refresh(serverUrl: String, refreshToken: String): AuthResponse { + return post(serverUrl, "/api/auth/refresh", null, xyz.kusoft.qmax.core.model.RefreshTokenRequest(refreshToken)) + } + + suspend fun chats(sessionServerUrl: String, token: String): List { + return get(sessionServerUrl, "/api/chats", token) + } + + suspend fun createDirect(sessionServerUrl: String, token: String, externalChatId: String, title: String): ChatDto { + return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title)) + } + + suspend fun messages(sessionServerUrl: String, token: String, chatId: String): List { + return get(sessionServerUrl, "/api/chats/$chatId/messages", token) + } + + suspend fun markChatRead(sessionServerUrl: String, token: String, chatId: String) { + postNoContent(sessionServerUrl, "/api/chats/$chatId/read", token, MarkChatReadRequest()) + } + + suspend fun searchMessages(sessionServerUrl: String, token: String, chatId: String, query: String): List { + val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString()) + return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token) + } + + suspend fun chatPresence(sessionServerUrl: String, token: String, chatId: String): ChatPresenceDto { + return get(sessionServerUrl, "/api/chats/$chatId/presence", token) + } + + suspend fun sendMessage( + sessionServerUrl: String, + token: String, + chatId: String, + text: String, + replyToMessageId: String? + ): MessageDto { + return post(sessionServerUrl, "/api/chats/$chatId/messages", token, SendMessageRequest(text, replyToMessageId)) + } + + suspend fun editMessage( + sessionServerUrl: String, + token: String, + chatId: String, + messageId: String, + text: String + ): MessageDto { + return patch(sessionServerUrl, "/api/chats/$chatId/messages/$messageId", token, EditMessageRequest(text)) + } + + suspend fun deleteMessage(sessionServerUrl: String, token: String, chatId: String, messageId: String) { + delete(sessionServerUrl, "/api/chats/$chatId/messages/$messageId", token) + } + + suspend fun setReaction(sessionServerUrl: String, token: String, chatId: String, messageId: String, emoji: String): MessageDto { + return put(sessionServerUrl, "/api/chats/$chatId/messages/$messageId/reaction", token, SetReactionRequest(emoji)) + } + + suspend fun clearReaction(sessionServerUrl: String, token: String, chatId: String, messageId: String): MessageDto { + return deleteForJson(sessionServerUrl, "/api/chats/$chatId/messages/$messageId/reaction", token) + } + + suspend fun forwardMessage( + sessionServerUrl: String, + token: String, + sourceChatId: String, + messageId: String, + targetChatId: String + ): MessageDto { + return post( + sessionServerUrl, + "/api/chats/$sourceChatId/messages/$messageId/forward", + token, + ForwardMessageRequest(targetChatId) + ) + } + + suspend fun uploadAttachment( + sessionServerUrl: String, + token: String, + chatId: String, + fileName: String, + contentType: String, + body: RequestBody, + caption: String?, + replyToMessageId: String? + ): MessageDto { + val multipart = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", fileName, body) + .apply { + if (!caption.isNullOrBlank()) addFormDataPart("caption", caption) + if (!replyToMessageId.isNullOrBlank()) addFormDataPart("replyToMessageId", replyToMessageId) + } + .build() + + val request = Request.Builder() + .url("${sessionServerUrl.trimEnd('/')}/api/chats/$chatId/attachments") + .header("Authorization", "Bearer $token") + .post(multipart) + .build() + + return execute(request) + } + + suspend fun maxStatus(sessionServerUrl: String, token: String): MaxBridgeStatusDto { + return get(sessionServerUrl, "/api/max/status", token) + } + + suspend fun startMaxLogin(sessionServerUrl: String, token: String, phone: String?): MaxBridgeStatusDto { + return post(sessionServerUrl, "/api/max/login/start", token, BeginMaxLoginRequest(phone)) + } + + suspend fun submitMaxCode(sessionServerUrl: String, token: String, code: String): MaxBridgeStatusDto { + return post(sessionServerUrl, "/api/max/login/code", token, SubmitMaxCodeRequest(code)) + } + + suspend fun registerPushDevice(sessionServerUrl: String, token: String, firebaseToken: String) { + postNoContent(sessionServerUrl, "/api/push/devices", token, RegisterPushDeviceRequest(firebaseToken)) + } + + fun absoluteUrl(serverUrl: String, path: String): String { + return if (path.startsWith("http")) path else "${serverUrl.trimEnd('/')}$path" + } + + suspend fun argusManifest( + baseUrl: String = "https://argus.kusoft.xyz", + slug: String = "qmax", + platform: String = "android", + channel: String = "stable" + ): ArgusManifestDto? { + val request = Request.Builder() + .url("${baseUrl.trimEnd('/')}/api/apps/$slug/manifest?platform=$platform&channel=$channel") + .get() + .build() + + return withContext(Dispatchers.IO) { + debugLog("GET ${request.url.encodedPath}") + client.newCall(request).execute().use { response -> + if (response.code == 404) return@withContext null + val body = response.body?.string().orEmpty() + debugLog("GET ${request.url.encodedPath} -> ${response.code}") + if (!response.isSuccessful) throw QMaxHttpException(response.code, httpErrorMessage(response.code, body)) + json.decodeFromString(body) + } + } + } + + suspend fun downloadToFile(url: String, target: File, token: String? = null, expectedSize: Long? = null): Long { + val request = Request.Builder() + .url(url) + .apply { + if (!token.isNullOrBlank()) header("Authorization", "Bearer $token") + } + .get() + .build() + return withContext(Dispatchers.IO) { + debugLog("DOWNLOAD ${request.url.encodedPath}") + downloadClient.newCall(request).execute().use { response -> + debugLog("DOWNLOAD ${request.url.encodedPath} -> ${response.code}") + target.parentFile?.mkdirs() + val part = File(target.parentFile, target.name + ".part") + if (part.exists()) part.delete() + if (!response.isSuccessful) { + val responseBody = response.body?.string().orEmpty() + throw QMaxHttpException(response.code, httpErrorMessage(response.code, responseBody)) + } + response.body?.byteStream().use { input -> + requireNotNull(input) { "Empty response body" } + FileOutputStream(part).use { output -> + input.copyTo(output) + } + } + if (expectedSize != null && expectedSize > 0 && part.length() != expectedSize) { + val actual = part.length() + part.delete() + throw IOException("Download size mismatch: expected $expectedSize, got $actual") + } + if (target.exists()) target.delete() + if (!part.renameTo(target)) { + part.delete() + throw IOException("Cannot move downloaded file into cache") + } + target.length() + } + } + } + + private suspend inline fun get(serverUrl: String, path: String, token: String): T { + val request = Request.Builder() + .url("${serverUrl.trimEnd('/')}$path") + .header("Authorization", "Bearer $token") + .get() + .build() + return execute(request) + } + + private suspend inline fun post(serverUrl: String, path: String, token: String?, body: B): T { + val requestBody = json.encodeToString(body).toRequestBody(jsonMediaType) + val request = Request.Builder() + .url("${serverUrl.trimEnd('/')}$path") + .apply { + if (!token.isNullOrBlank()) header("Authorization", "Bearer $token") + } + .post(requestBody) + .build() + return execute(request) + } + + private suspend inline fun put(serverUrl: String, path: String, token: String, body: B): T { + val requestBody = json.encodeToString(body).toRequestBody(jsonMediaType) + val request = Request.Builder() + .url("${serverUrl.trimEnd('/')}$path") + .header("Authorization", "Bearer $token") + .put(requestBody) + .build() + return execute(request) + } + + private suspend inline fun patch(serverUrl: String, path: String, token: String, body: B): T { + val requestBody = json.encodeToString(body).toRequestBody(jsonMediaType) + val request = Request.Builder() + .url("${serverUrl.trimEnd('/')}$path") + .header("Authorization", "Bearer $token") + .patch(requestBody) + .build() + return execute(request) + } + + private suspend fun delete(serverUrl: String, path: String, token: String) { + val request = Request.Builder() + .url("${serverUrl.trimEnd('/')}$path") + .header("Authorization", "Bearer $token") + .delete() + .build() + + withContext(Dispatchers.IO) { + debugLog("DELETE ${request.url.encodedPath}") + client.newCall(request).execute().use { response -> + val responseBody = response.body?.string().orEmpty() + debugLog("DELETE ${request.url.encodedPath} -> ${response.code}") + if (!response.isSuccessful) { + throw QMaxHttpException(response.code, httpErrorMessage(response.code, responseBody)) + } + } + } + } + + private suspend inline fun deleteForJson(serverUrl: String, path: String, token: String): T { + val request = Request.Builder() + .url("${serverUrl.trimEnd('/')}$path") + .header("Authorization", "Bearer $token") + .delete() + .build() + return execute(request) + } + + private suspend inline fun postNoContent(serverUrl: String, path: String, token: String, body: B) { + val requestBody = json.encodeToString(body).toRequestBody(jsonMediaType) + val request = Request.Builder() + .url("${serverUrl.trimEnd('/')}$path") + .header("Authorization", "Bearer $token") + .post(requestBody) + .build() + + withContext(Dispatchers.IO) { + debugLog("POST ${request.url.encodedPath}") + client.newCall(request).execute().use { response -> + val responseBody = response.body?.string().orEmpty() + debugLog("POST ${request.url.encodedPath} -> ${response.code}") + if (!response.isSuccessful) { + throw QMaxHttpException(response.code, httpErrorMessage(response.code, responseBody)) + } + } + } + } + + private suspend inline fun execute(request: Request): T { + debugLog("${request.method} ${request.url.encodedPath}") + val result = executeCancellable(request) + debugLog("${request.method} ${request.url.encodedPath} -> ${result.code}") + if (!result.isSuccessful) { + throw QMaxHttpException(result.code, httpErrorMessage(result.code, result.body)) + } + return json.decodeFromString(result.body) + } + + private suspend fun executeCancellable(request: Request): HttpResult { + return suspendCancellableCoroutine { continuation -> + val call = client.newCall(request) + continuation.invokeOnCancellation { call.cancel() } + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (continuation.isActive) { + continuation.resumeWithException(e) + } + } + + override fun onResponse(call: Call, response: Response) { + response.use { + try { + val body = it.body?.string().orEmpty() + if (continuation.isActive) { + continuation.resume(HttpResult(it.code, it.isSuccessful, body)) + } + } catch (error: Throwable) { + if (continuation.isActive) { + continuation.resumeWithException(error) + } + } + } + } + }) + } + } + + private data class HttpResult( + val code: Int, + val isSuccessful: Boolean, + val body: String + ) + + private fun httpErrorMessage(code: Int, body: String): String { + val trimmed = body.trim() + if (trimmed.isBlank()) { + return "HTTP $code" + } + + runCatching { + val jsonObject = json.parseToJsonElement(trimmed).jsonObject + listOf("detail", "error", "message", "title") + .firstNotNullOfOrNull { key -> + jsonObject[key]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotBlank() } + } + }.getOrNull()?.let { message -> + return message + } + + return trimmed.take(500) + } + + private fun debugLog(message: String) { + if (BuildConfig.DEBUG) { + Log.d(LogTag, message) + } + } + + private companion object { + const val LogTag = "QMAX" + } +} diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/push/FirebaseBootstrap.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/push/FirebaseBootstrap.kt new file mode 100644 index 0000000..f4daf8d --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/push/FirebaseBootstrap.kt @@ -0,0 +1,99 @@ +package xyz.kusoft.qmax.core.push + +import android.content.Context +import android.util.Log +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import org.json.JSONObject + +object FirebaseBootstrap { + private const val tag = "QMaxFirebase" + + fun ensureInitialized(context: Context): Boolean { + if (FirebaseApp.getApps(context).isNotEmpty()) { + return true + } + + val config = readFirebaseAndroidConfig(context) + ?: readGoogleServicesConfig(context) + ?: return false + + return runCatching { + val options = FirebaseOptions.Builder() + .setApplicationId(config.applicationId) + .setApiKey(config.apiKey) + .setProjectId(config.projectId) + .setGcmSenderId(config.senderId) + .build() + FirebaseApp.initializeApp(context, options) + true + }.onFailure { + Log.w(tag, "Firebase initialization failed", it) + }.getOrDefault(false) + } + + private fun readFirebaseAndroidConfig(context: Context): FirebaseConfig? { + val json = assetText(context, "firebase.android.json") ?: return null + val obj = JSONObject(json) + return FirebaseConfig( + applicationId = obj.optString("applicationId"), + apiKey = obj.optString("apiKey"), + projectId = obj.optString("projectId"), + senderId = obj.optString("senderId") + ).takeIf { it.isComplete } + } + + private fun readGoogleServicesConfig(context: Context): FirebaseConfig? { + val json = assetText(context, "google-services.json") ?: return null + val obj = JSONObject(json) + val project = obj.getJSONObject("project_info") + val clients = obj.optJSONArray("client") ?: return null + var fallback: FirebaseConfig? = null + + for (index in 0 until clients.length()) { + val client = clients.getJSONObject(index) + val clientInfo = client.getJSONObject("client_info") + val packageName = clientInfo + .getJSONObject("android_client_info") + .optString("package_name") + val apiKey = client + .getJSONArray("api_key") + .getJSONObject(0) + .optString("current_key") + val config = FirebaseConfig( + applicationId = clientInfo.optString("mobilesdk_app_id"), + apiKey = apiKey, + projectId = project.optString("project_id"), + senderId = project.optString("project_number") + ).takeIf { it.isComplete } + + if (packageName == context.packageName) { + return config + } + if (fallback == null) { + fallback = config + } + } + + return fallback + } + + private fun assetText(context: Context, name: String): String? { + return runCatching { + context.assets.open(name).bufferedReader().use { it.readText() } + }.getOrNull() + } + + private data class FirebaseConfig( + val applicationId: String, + val apiKey: String, + val projectId: String, + val senderId: String + ) { + val isComplete: Boolean + get() = applicationId.isNotBlank() && + apiKey.isNotBlank() && + projectId.isNotBlank() && + senderId.isNotBlank() + } +} diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/push/ForegroundChatTracker.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/push/ForegroundChatTracker.kt new file mode 100644 index 0000000..2d418a8 --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/push/ForegroundChatTracker.kt @@ -0,0 +1,36 @@ +package xyz.kusoft.qmax.core.push + +import android.content.Context + +object ForegroundChatTracker { + private const val PreferencesName = "qmax_foreground_chat" + private const val ActiveChatIdKey = "active_chat_id" + + fun setActiveChat(context: Context, chatId: String) { + context.applicationContext + .getSharedPreferences(PreferencesName, Context.MODE_PRIVATE) + .edit() + .putString(ActiveChatIdKey, chatId) + .apply() + } + + fun clearActiveChat(context: Context, chatId: String? = null) { + val preferences = context.applicationContext.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE) + if (chatId != null && preferences.getString(ActiveChatIdKey, null) != chatId) { + return + } + + preferences.edit().remove(ActiveChatIdKey).apply() + } + + fun isChatActive(context: Context, chatId: String?): Boolean { + if (chatId.isNullOrBlank()) { + return false + } + + return context.applicationContext + .getSharedPreferences(PreferencesName, Context.MODE_PRIVATE) + .getString(ActiveChatIdKey, null) + ?.equals(chatId, ignoreCase = true) == true + } +} diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/push/MarkChatReadReceiver.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/push/MarkChatReadReceiver.kt new file mode 100644 index 0000000..a74cad7 --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/push/MarkChatReadReceiver.kt @@ -0,0 +1,44 @@ +package xyz.kusoft.qmax.core.push + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.app.NotificationManagerCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import xyz.kusoft.qmax.core.QMaxContainer + +class MarkChatReadReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != PushNotifications.MarkChatReadAction) { + return + } + + val chatId = intent.getStringExtra(PushNotifications.ExtraChatId) + ?.takeIf { it.isNotBlank() } + ?: return + val notificationId = intent.getIntExtra(PushNotifications.ExtraNotificationId, 0) + if (notificationId != 0) { + NotificationManagerCompat.from(context).cancel(notificationId) + } + + val pendingResult = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + val container = QMaxContainer(context.applicationContext) + val session = container.tokenStore.session.firstOrNull() + if (session != null) { + container.repository.markChatRead(session, chatId) + } + } catch (error: Throwable) { + Log.w("QMaxPush", "Unable to mark notification chat as read", error) + } finally { + pendingResult.finish() + } + } + } +} diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/push/PushNotifications.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/push/PushNotifications.kt new file mode 100644 index 0000000..20a5dd9 --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/push/PushNotifications.kt @@ -0,0 +1,34 @@ +package xyz.kusoft.qmax.core.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build + +object PushNotifications { + const val MessagesChannelId = "qmax_messages_v2" + const val OpenChatAction = "QMAX_OPEN_CHAT" + const val MarkChatReadAction = "QMAX_MARK_CHAT_READ" + const val ExtraChatId = "chatId" + const val ExtraMessageId = "messageId" + const val ExtraNotificationId = "notificationId" + + fun ensureChannels(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + val manager = context.getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + MessagesChannelId, + "QMAX messages", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Incoming QMAX messages" + enableVibration(true) + setShowBadge(true) + lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC + } + manager.createNotificationChannel(channel) + } +} diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/push/QMaxFirebaseMessagingService.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/push/QMaxFirebaseMessagingService.kt new file mode 100644 index 0000000..782c4a9 --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/push/QMaxFirebaseMessagingService.kt @@ -0,0 +1,179 @@ +package xyz.kusoft.qmax.core.push + +import android.app.PendingIntent +import android.content.Intent +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import xyz.kusoft.qmax.MainActivity +import xyz.kusoft.qmax.QMaxApplication +import xyz.kusoft.qmax.R + +class QMaxFirebaseMessagingService : FirebaseMessagingService() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onNewToken(token: String) { + super.onNewToken(token) + registerToken(token) + } + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + PushNotifications.ensureChannels(this) + + val type = message.data["type"] + if (type != null && type != "new_message") { + return + } + + val title = message.notification?.title + ?: message.data["title"] + ?: message.data["chatTitle"] + ?: "QMAX" + val body = message.notification?.body + ?: message.data["body"] + ?: "New message" + if (isPresenceText(body)) { + return + } + + val chatId = message.data[PushNotifications.ExtraChatId] + val messageId = message.data[PushNotifications.ExtraMessageId] + if (ForegroundChatTracker.isChatActive(this, chatId)) { + return + } + + val notificationId = stableNotificationId(chatId ?: messageId ?: body) + val intent = Intent(this, MainActivity::class.java) + .setAction(PushNotifications.OpenChatAction) + .putExtra(PushNotifications.ExtraChatId, chatId) + .putExtra(PushNotifications.ExtraMessageId, messageId) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + val pendingIntent = PendingIntent.getActivity( + this, + notificationId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val markReadPendingIntent = chatId?.takeIf { it.isNotBlank() }?.let { id -> + val markReadIntent = Intent(this, MarkChatReadReceiver::class.java) + .setAction(PushNotifications.MarkChatReadAction) + .putExtra(PushNotifications.ExtraChatId, id) + .putExtra(PushNotifications.ExtraNotificationId, notificationId) + PendingIntent.getBroadcast( + this, + notificationId + 1, + markReadIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + val notification = NotificationCompat.Builder(this, PushNotifications.MessagesChannelId) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .apply { + if (markReadPendingIntent != null) { + addAction( + R.drawable.ic_mark_read_24, + "\u041f\u0440\u043e\u0447\u0438\u0442\u0430\u043d\u043e", + markReadPendingIntent + ) + } + } + .build() + + runCatching { + NotificationManagerCompat.from(this) + .notify(notificationId, notification) + }.onFailure { + Log.w("QMaxPush", "Unable to show notification", it) + } + } + + private fun registerToken(token: String) { + if (token.isBlank()) { + return + } + + scope.launch { + val container = (application as QMaxApplication).container + val session = container.tokenStore.session.firstOrNull() ?: return@launch + runCatching { + container.repository.registerPushDevice(session, token) + }.onFailure { + Log.w("QMaxPush", "Unable to register refreshed FCM token", it) + } + } + } + + private fun stableNotificationId(value: String): Int { + return value.hashCode() and Int.MAX_VALUE + } + + private fun isPresenceText(value: String): Boolean { + val text = value + .trim() + .lowercase() + .replace("...", "") + .replace("\u2026", "") + .trim() + if (text.isBlank() || text.length > 120) { + return false + } + + if (text.contains("\u043f\u0435\u0447\u0430\u0442") || + text.contains("\u043d\u0430\u0431\u0438\u0440\u0430") || + text.contains("typing") + ) { + return true + } + + val hasProcessVerb = text.contains("\u043e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u0442") || + text.contains("\u0432\u044b\u0431\u0438\u0440\u0430\u0435\u0442") || + text.contains("\u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u0442") || + text.contains("\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u044f\u0435\u0442") || + text.contains("\u0437\u0430\u043f\u0438\u0441\u044b\u0432\u0430\u0435\u0442") || + text.contains("is sending") || + text.contains("sending") || + text.contains("choosing") || + text.contains("uploading") || + text.contains("recording") + if (!hasProcessVerb) { + return false + } + + return text.contains("\u0444\u043e\u0442\u043e") || + text.contains("\u0438\u0437\u043e\u0431\u0440\u0430\u0436") || + text.contains("\u043a\u0430\u0440\u0442\u0438\u043d") || + text.contains("\u0432\u0438\u0434\u0435\u043e") || + text.contains("\u0441\u0442\u0438\u043a\u0435\u0440") || + text.contains("\u044d\u043c\u043e\u0434\u0437\u0438") || + text.contains("\u0433\u043e\u043b\u043e\u0441") || + text.contains("\u0430\u0443\u0434\u0438\u043e") || + text.contains("\u0444\u0430\u0439\u043b") || + text.contains("photo") || + text.contains("image") || + text.contains("video") || + text.contains("sticker") || + text.contains("emoji") || + text.contains("voice") || + text.contains("audio") || + text.contains("file") + } +} 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 new file mode 100644 index 0000000..d5db72e --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/realtime/QMaxRealtimeClient.kt @@ -0,0 +1,101 @@ +package xyz.kusoft.qmax.core.realtime + +import com.google.gson.JsonElement +import com.microsoft.signalr.HubConnection +import com.microsoft.signalr.HubConnectionBuilder +import com.microsoft.signalr.HubConnectionState +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.MessageDeletedDto +import xyz.kusoft.qmax.core.model.MessageDto +import xyz.kusoft.qmax.core.model.QMaxSession + +class QMaxRealtimeClient { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private var hub: HubConnection? = null + private var joinedChatId: String? = null + + suspend fun connect( + session: QMaxSession, + onChatListInvalidated: () -> Unit, + onMessageCreated: (MessageDto) -> Unit, + onMessageUpdated: (MessageDto) -> Unit, + onMessageDeleted: (MessageDeletedDto) -> Unit + ) = withContext(Dispatchers.IO) { + disconnect() + + val connection = HubConnectionBuilder + .create("${session.serverUrl.trimEnd('/')}/hubs/qmax") + .withAccessTokenProvider(Single.just(session.accessToken)) + .build() + + connection.on("ChatListInvalidated", onChatListInvalidated) + connection.on( + "MessageCreated", + { payload: JsonElement -> + runCatching { + onMessageCreated(json.decodeFromString(payload.toString())) + } + }, + JsonElement::class.java + ) + connection.on( + "MessageUpdated", + { payload: JsonElement -> + runCatching { + onMessageUpdated(json.decodeFromString(payload.toString())) + } + }, + JsonElement::class.java + ) + connection.on( + "MessageDeleted", + { payload: JsonElement -> + runCatching { + onMessageDeleted(json.decodeFromString(payload.toString())) + } + }, + JsonElement::class.java + ) + connection.onClosed { + joinedChatId = null + } + + connection.start().blockingAwait() + hub = connection + } + + suspend fun joinChat(chatId: String?) = withContext(Dispatchers.IO) { + val connection = hub ?: return@withContext + if (connection.connectionState != HubConnectionState.CONNECTED) { + return@withContext + } + + val previous = joinedChatId + if (!previous.isNullOrBlank() && previous != chatId) { + runCatching { connection.send("LeaveChat", previous) } + } + + if (!chatId.isNullOrBlank() && previous != chatId) { + connection.send("JoinChat", chatId) + joinedChatId = chatId + } else if (chatId.isNullOrBlank()) { + joinedChatId = null + } + } + + suspend fun disconnect() = withContext(Dispatchers.IO) { + val connection = hub + hub = null + joinedChatId = null + if (connection != null && connection.connectionState != HubConnectionState.DISCONNECTED) { + runCatching { connection.stop().blockingAwait() } + } + } +} diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/settings/MessageDraftStore.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/settings/MessageDraftStore.kt new file mode 100644 index 0000000..43be793 --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/settings/MessageDraftStore.kt @@ -0,0 +1,54 @@ +package xyz.kusoft.qmax.core.settings + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +private val Context.qmaxDraftDataStore by preferencesDataStore("qmax_drafts") + +class MessageDraftStore(private val context: Context) { + private val prefix = "draft_" + + val drafts: Flow> = context.qmaxDraftDataStore.data.map { prefs -> + prefs.asMap().mapNotNull { (key, value) -> + val name = key.name + val text = value as? String + if (!name.startsWith(prefix) || text.isNullOrBlank()) { + null + } else { + name.removePrefix(prefix) to text + } + }.toMap() + } + + suspend fun draft(chatId: String): String { + return context.qmaxDraftDataStore.data + .map { it[draftKey(chatId)].orEmpty() } + .first() + } + + suspend fun save(chatId: String, text: String) { + context.qmaxDraftDataStore.edit { prefs -> + val key = draftKey(chatId) + if (text.isBlank()) { + prefs.remove(key) + } else { + prefs[key] = text + } + } + } + + suspend fun clear(chatId: String) { + context.qmaxDraftDataStore.edit { it.remove(draftKey(chatId)) } + } + + suspend fun clearAll() { + context.qmaxDraftDataStore.edit { it.clear() } + } + + private fun draftKey(chatId: String) = stringPreferencesKey("$prefix$chatId") +} diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/settings/TokenStore.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/settings/TokenStore.kt new file mode 100644 index 0000000..ea2957f --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/settings/TokenStore.kt @@ -0,0 +1,47 @@ +package xyz.kusoft.qmax.core.settings + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import xyz.kusoft.qmax.BuildConfig +import xyz.kusoft.qmax.core.model.QMaxSession + +private val Context.qmaxDataStore by preferencesDataStore("qmax") + +class TokenStore(private val context: Context) { + private val serverUrlKey = stringPreferencesKey("server_url") + private val accessTokenKey = stringPreferencesKey("access_token") + private val refreshTokenKey = stringPreferencesKey("refresh_token") + private val userNameKey = stringPreferencesKey("user_name") + + val session: Flow = context.qmaxDataStore.data.map { prefs -> + val accessToken = prefs[accessTokenKey] + val refreshToken = prefs[refreshTokenKey] + if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) { + null + } else { + QMaxSession( + serverUrl = prefs[serverUrlKey] ?: BuildConfig.QMAX_DEFAULT_SERVER_URL, + accessToken = accessToken, + refreshToken = refreshToken, + userName = prefs[userNameKey] ?: "QMAX" + ) + } + } + + suspend fun save(session: QMaxSession) { + context.qmaxDataStore.edit { prefs -> + prefs[serverUrlKey] = session.serverUrl + prefs[accessTokenKey] = session.accessToken + prefs[refreshTokenKey] = session.refreshToken + prefs[userNameKey] = session.userName + } + } + + suspend fun clear() { + context.qmaxDataStore.edit { it.clear() } + } +} 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 new file mode 100644 index 0000000..ef6665d --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt @@ -0,0 +1,950 @@ +package xyz.kusoft.qmax.ui + +import android.net.Uri +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import xyz.kusoft.qmax.BuildConfig +import xyz.kusoft.qmax.core.QMaxRepository +import xyz.kusoft.qmax.core.model.AttachmentDto +import xyz.kusoft.qmax.core.model.ChatDto +import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto +import xyz.kusoft.qmax.core.model.MessageDeletedDto +import xyz.kusoft.qmax.core.model.MessageDto +import xyz.kusoft.qmax.core.model.QMaxSession +import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient +import java.io.File + +data class QMaxUiState( + val serverUrl: String = BuildConfig.QMAX_DEFAULT_SERVER_URL, + val pairingCode: String = BuildConfig.QMAX_DEFAULT_PAIRING_CODE, + val session: QMaxSession? = null, + val chats: List = emptyList(), + val selectedChat: ChatDto? = null, + val messages: List = emptyList(), + val chatSearchResults: List = emptyList(), + val chatSearchLoading: Boolean = false, + val chatPresenceText: String? = null, + val replyTarget: MessageDto? = null, + val editTarget: MessageDto? = null, + val forwardTarget: MessageDto? = null, + val drafts: Map = emptyMap(), + val searchQuery: String = "", + val composerText: String = "", + val newChatExternalId: String = "", + val newChatTitle: String = "", + val maxStatus: MaxBridgeStatusDto? = null, + val maxCode: String = "", + val updateMessage: String? = null, + val loading: Boolean = false, + val sendingMessage: Boolean = false, + val chatListLoading: Boolean = false, + val chatListConnectionIssue: Boolean = false, + val error: String? = null, + val previewImageUrl: String? = null, + val previewImageGallery: List = emptyList(), + val previewImageIndex: Int = 0, + val cachedImagePaths: Map = emptyMap() +) + +class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { + var state = androidx.compose.runtime.mutableStateOf(QMaxUiState()) + private set + + private val realtime = QMaxRealtimeClient() + private var messagePollJob: Job? = null + private var presencePollJob: Job? = null + private var realtimeConnectJob: Job? = null + private var chatListRefreshJob: Job? = null + private var chatRefreshJob: Job? = null + private var chatRefreshGeneration = 0L + private var chatSearchJob: Job? = null + private var autoLoginJob: Job? = null + private var pushRegisteredForToken: String? = null + private var autoLoginAttempted = false + private var pendingOpenChatId: String? = null + private val locallyReadChatFingerprints = mutableMapOf() + private val imageCacheInFlight = mutableSetOf() + + init { + viewModelScope.launch { + repository.session.collectLatest { session -> + val previousSession = state.value.session + val sessionChanged = previousSession?.serverUrl != session?.serverUrl || + previousSession?.userName != session?.userName + state.value = state.value.copy( + session = session, + serverUrl = session?.serverUrl ?: state.value.serverUrl, + cachedImagePaths = if (sessionChanged) emptyMap() else state.value.cachedImagePaths + ) + if (session != null) { + autoLoginJob?.cancel() + restoreCachedChats(session) + loadChats() + connectRealtime(session) + registerPushDevice(session) + loadMaxStatus() + } else { + realtimeConnectJob?.cancel() + messagePollJob?.cancel() + presencePollJob?.cancel() + realtime.disconnect() + if (!autoLoginAttempted && state.value.pairingCode.isNotBlank()) { + autoLoginAttempted = true + autoLoginJob?.cancel() + autoLoginJob = viewModelScope.launch { + delay(AutoLoginDelayMs) + if (state.value.session == null) { + login() + } + } + } + } + } + } + + viewModelScope.launch { + repository.drafts.collectLatest { drafts -> + state.value = state.value.copy(drafts = drafts) + } + } + } + + fun updateServerUrl(value: String) { + state.value = state.value.copy(serverUrl = value) + } + + fun updatePairingCode(value: String) { + state.value = state.value.copy(pairingCode = value) + } + + fun updateComposer(value: String) { + state.value = state.value.copy(composerText = value) + if (state.value.editTarget != null) { + return + } + val chatId = state.value.selectedChat?.id ?: return + viewModelScope.launch { + repository.saveDraft(chatId, value) + } + } + + fun updateSearchQuery(value: String) { + state.value = state.value.copy(searchQuery = value) + } + + fun updateNewChatExternalId(value: String) { + state.value = state.value.copy(newChatExternalId = value) + } + + fun updateNewChatTitle(value: String) { + state.value = state.value.copy(newChatTitle = value) + } + + fun updateMaxCode(value: String) { + state.value = state.value.copy(maxCode = value) + } + + fun login() = launchLoading { + val current = state.value + repository.login(current.serverUrl, current.pairingCode) + } + + fun logout() = viewModelScope.launch { + autoLoginAttempted = true + repository.logout() + realtime.disconnect() + state.value = QMaxUiState(serverUrl = state.value.serverUrl, pairingCode = "") + } + + fun loadChats() = refreshChats() + + private suspend fun restoreCachedChats(session: QMaxSession) { + runCatching { + repository.cachedChats(session) + }.getOrNull()?.takeIf { it.isNotEmpty() }?.let { cached -> + val currentSession = state.value.session + if (currentSession?.serverUrl == session.serverUrl && currentSession.userName == session.userName) { + val orderedCached = normalizeChats(cached) + state.value = state.value.copy( + chats = orderedCached, + chatListLoading = false, + chatListConnectionIssue = false, + error = null + ) + openPendingChatIfAvailable(orderedCached) + } + } + } + + private fun refreshChats(showLoading: Boolean = true) { + if (showLoading) { + chatRefreshJob?.cancel() + } else if (chatRefreshJob?.isActive == true) { + return + } + + val generation = ++chatRefreshGeneration + chatRefreshJob = viewModelScope.launch { + if (showLoading) { + state.value = state.value.copy( + chatListLoading = state.value.chats.isEmpty(), + error = null + ) + } + try { + val session = requireSession() + val cached = repository.cachedChats(session) + if (cached.isNotEmpty()) { + val orderedCached = normalizeChats(cached) + state.value = state.value.copy( + chats = orderedCached, + chatListLoading = false + ) + openPendingChatIfAvailable(orderedCached) + } + + val fresh = withTimeout(ChatListTimeoutMs) { + repository.chats(session) + } + if (fresh.isEmpty()) { + error("Chat list response is empty.") + } + val orderedFresh = normalizeChats(fresh) + repository.cacheChats(session, orderedFresh) + state.value = state.value.copy( + chats = orderedFresh, + chatListLoading = false, + chatListConnectionIssue = false, + error = null + ) + openPendingChatIfAvailable(orderedFresh) + } catch (error: TimeoutCancellationException) { + Log.w(LogTag, "chat refresh timed out", error) + if (generation == chatRefreshGeneration) { + state.value = state.value.copy( + chatListLoading = false, + chatListConnectionIssue = true + ) + } + } catch (error: CancellationException) { + if (generation == chatRefreshGeneration) { + throw error + } + } catch (error: Throwable) { + Log.w(LogTag, "chat refresh failed", error) + if (generation == chatRefreshGeneration) { + state.value = state.value.copy( + chatListLoading = false, + chatListConnectionIssue = true + ) + } + } finally { + if (showLoading && generation == chatRefreshGeneration) { + state.value = state.value.copy(chatListLoading = false) + } + } + } + } + + fun createChat() = launchLoading { + val session = requireSession() + val current = state.value + val externalId = current.newChatExternalId.ifBlank { current.newChatTitle } + val title = current.newChatTitle.ifBlank { externalId } + if (externalId.isBlank()) return@launchLoading + val chat = repository.createChat(session, externalId, title) + state.value = state.value.copy(newChatExternalId = "", newChatTitle = "") + openChat(chat) + loadChats() + } + + fun openChat(chat: ChatDto) { + saveCurrentDraft() + chatSearchJob?.cancel() + val openedChat = chat.copy(unreadCount = 0) + rememberChatRead(openedChat) + val updatedChats = normalizeChats( + state.value.chats.map { current -> + if (current.id == chat.id) current.copy(unreadCount = 0) else current + }, + selectedChatId = chat.id + ) + state.value = state.value.copy( + selectedChat = openedChat, + chats = updatedChats, + messages = emptyList(), + chatSearchResults = emptyList(), + chatSearchLoading = false, + chatPresenceText = null, + replyTarget = null, + editTarget = null, + forwardTarget = null, + composerText = "" + ) + persistCurrentChats() + syncChatRead(chat.id) + messagePollJob?.cancel() + viewModelScope.launch { + val draft = repository.draft(chat.id) + if (state.value.selectedChat?.id == chat.id) { + state.value = state.value.copy(composerText = draft) + } + realtime.joinChat(chat.id) + } + loadMessages(showLoading = true) + presencePollJob?.cancel() + presencePollJob = viewModelScope.launch { + while (state.value.selectedChat?.id == chat.id) { + refreshPresence(chat.id) + delay(2_000) + } + } + messagePollJob = viewModelScope.launch { + while (true) { + delay(30000) + if (!state.value.sendingMessage) { + loadMessages(showLoading = false) + } + } + } + } + + fun openChatById(chatId: String) { + val chat = state.value.chats.firstOrNull { it.id.equals(chatId, ignoreCase = true) } + if (chat != null) { + pendingOpenChatId = null + if (state.value.selectedChat?.id != chat.id) { + openChat(chat) + } + return + } + + pendingOpenChatId = chatId + if (state.value.session != null) { + loadChats() + } + } + + fun closeChat() { + val closingChat = state.value.selectedChat + val closingChatId = closingChat?.id + saveCurrentDraft() + messagePollJob?.cancel() + presencePollJob?.cancel() + viewModelScope.launch { + realtime.joinChat(null) + } + chatSearchJob?.cancel() + val reorderedChats = if (closingChatId.isNullOrBlank()) { + state.value.chats + } else { + closingChat?.let(::rememberChatRead) + normalizeChats(state.value.chats.map { chat -> + if (chat.id == closingChatId) chat.copy(unreadCount = 0) else chat + }, selectedChatId = closingChatId) + } + state.value = state.value.copy( + selectedChat = null, + chats = reorderedChats, + messages = emptyList(), + chatSearchResults = emptyList(), + chatSearchLoading = false, + chatPresenceText = null, + replyTarget = null, + editTarget = null, + forwardTarget = null, + composerText = "" + ) + persistCurrentChats() + closingChatId?.let(::syncChatRead) + } + + fun loadMessages(showLoading: Boolean = true) = launchLoading(showLoading) { + if (!showLoading && state.value.sendingMessage) { + return@launchLoading + } + val session = requireSession() + val chat = state.value.selectedChat ?: return@launchLoading + val cached = repository.cachedMessages(session, chat.id) + if (cached.isNotEmpty() && state.value.selectedChat?.id == chat.id && state.value.messages.isEmpty()) { + state.value = state.value.copy(messages = cached) + } + val fresh = withTimeout(MessageSyncTimeoutMs) { + repository.messages(session, chat.id) + } + if (state.value.selectedChat?.id == chat.id) { + val selected = state.value.selectedChat?.copy(unreadCount = 0) + selected?.let(::rememberChatRead) + state.value = state.value.copy( + selectedChat = selected, + chats = normalizeChats(state.value.chats, selectedChatId = chat.id), + messages = fresh + ) + persistCurrentChats() + } + } + + fun sendMessage() = sendComposer(emptyList()) + + fun sendComposer(attachmentUris: List, onSuccess: () -> Unit = {}) = launchLoading(false) { + val session = requireSession() + val chat = state.value.selectedChat ?: return@launchLoading + val text = state.value.composerText.trim() + if (text.isBlank() && attachmentUris.isEmpty()) return@launchLoading + + state.value = state.value.copy(sendingMessage = true, error = null) + try { + val editTarget = state.value.editTarget + if (editTarget != null) { + if (text.isBlank()) return@launchLoading + val edited = repository.editMessage(session, chat.id, editTarget.id, text) + repository.clearDraft(chat.id) + val current = state.value + if (current.selectedChat?.id == chat.id) { + state.value = current.copy( + composerText = "", + editTarget = null, + replyTarget = null, + messages = upsertMessage(current.messages, edited) + ) + } + onSuccess() + loadChats() + return@launchLoading + } + + val replyToMessageId = state.value.replyTarget?.id + val sentMessages = mutableListOf() + + if (attachmentUris.isEmpty()) { + sentMessages += repository.sendMessage(session, chat.id, text, replyToMessageId) + } else { + attachmentUris.forEachIndexed { index, uri -> + val caption = text.takeIf { index == 0 && it.isNotBlank() } + sentMessages += repository.upload(session, chat.id, uri, caption, replyToMessageId) + } + } + + repository.clearDraft(chat.id) + val current = state.value + if (current.selectedChat?.id == chat.id) { + state.value = current.copy( + composerText = "", + replyTarget = null, + messages = sentMessages.fold(current.messages, ::upsertMessage) + ) + } + onSuccess() + loadChats() + } finally { + state.value = state.value.copy(sendingMessage = false) + } + } + + fun upload(uri: Uri) = launchLoading(false) { + val session = requireSession() + val chat = state.value.selectedChat ?: return@launchLoading + val caption = state.value.composerText.ifBlank { null } + val uploaded = repository.upload(session, chat.id, uri, caption, state.value.replyTarget?.id) + repository.clearDraft(chat.id) + val current = state.value + state.value = current.copy( + composerText = "", + replyTarget = null, + messages = if (current.selectedChat?.id == chat.id) { + upsertMessage(current.messages, uploaded) + } else { + current.messages + } + ) + loadChats() + } + + fun uploadVoice(file: File) = launchLoading(false) { + try { + val session = requireSession() + val chat = state.value.selectedChat ?: return@launchLoading + val uploaded = repository.uploadVoice(session, chat.id, file, state.value.replyTarget?.id) + val current = state.value + state.value = current.copy( + replyTarget = null, + messages = if (current.selectedChat?.id == chat.id) { + upsertMessage(current.messages, uploaded) + } else { + current.messages + } + ) + loadChats() + } finally { + file.delete() + } + } + + fun loadMaxStatus() = launchLoading(false) { + val session = requireSession() + state.value = state.value.copy(maxStatus = repository.maxStatus(session)) + } + + fun startMaxLogin() = launchLoading { + val session = requireSession() + state.value = state.value.copy(maxStatus = 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 = "") + } + + fun checkArgusUpdate() = launchLoading { + val manifest = repository.checkArgusUpdate() + if (manifest == null) { + state.value = state.value.copy(updateMessage = "Обновлений нет") + } else { + state.value = state.value.copy(updateMessage = "Найдена версия ${manifest.release.version}") + repository.downloadAndOpenArgusUpdate(manifest) + } + } + + fun openImage(url: String, gallery: List = emptyList()) { + val images = (gallery.ifEmpty { listOf(url) }) + .filter { it.isNotBlank() } + .distinct() + val selectedIndex = images.indexOf(url).takeIf { it >= 0 } ?: 0 + state.value = state.value.copy( + previewImageUrl = images.getOrNull(selectedIndex) ?: url, + previewImageGallery = images, + previewImageIndex = selectedIndex + ) + } + + fun closeImage() { + state.value = state.value.copy( + previewImageUrl = null, + previewImageGallery = emptyList(), + previewImageIndex = 0 + ) + } + + fun moveImagePreview(delta: Int) { + val gallery = state.value.previewImageGallery + if (gallery.size < 2) return + val nextIndex = (state.value.previewImageIndex + delta + gallery.size) % gallery.size + state.value = state.value.copy( + previewImageUrl = gallery[nextIndex], + previewImageIndex = nextIndex + ) + } + + fun openAttachment(attachment: AttachmentDto) = launchLoading(false) { + val session = requireSession() + repository.openAttachment(session, attachment) + } + + fun shareMessage(message: MessageDto) = launchLoading(false) { + val session = requireSession() + repository.shareMessage(session, message) + } + + fun shareAttachment(attachment: AttachmentDto) = launchLoading(false) { + val session = requireSession() + repository.shareAttachment(session, attachment) + } + + fun replyTo(message: MessageDto) { + state.value = state.value.copy(replyTarget = message, editTarget = null) + } + + fun clearReplyTarget() { + state.value = state.value.copy(replyTarget = null) + } + + fun openEditMessage(message: MessageDto) { + if (!message.direction.equals("Outgoing", ignoreCase = true) || message.text.isNullOrBlank()) { + return + } + state.value = state.value.copy( + editTarget = message, + replyTarget = null, + composerText = message.text + ) + } + + fun clearEditTarget() { + val chatId = state.value.selectedChat?.id + state.value = state.value.copy(editTarget = null, composerText = "") + if (chatId != null) { + viewModelScope.launch { + val draft = repository.draft(chatId) + if (state.value.selectedChat?.id == chatId && state.value.editTarget == null) { + state.value = state.value.copy(composerText = draft) + } + } + } + } + + fun deleteMessage(message: MessageDto) = launchLoading(false) { + val session = requireSession() + repository.deleteMessage(session, message.chatId, message.id) + val current = state.value + state.value = current.copy( + messages = current.messages.filterNot { it.id == message.id }, + replyTarget = current.replyTarget?.takeIf { it.id != message.id }, + editTarget = current.editTarget?.takeIf { it.id != message.id }, + forwardTarget = current.forwardTarget?.takeIf { it.id != message.id } + ) + loadChats() + } + + fun toggleReaction(message: MessageDto, emoji: String) = launchLoading(false) { + val session = requireSession() + val currentReaction = message.reactions.firstOrNull { it.reactedByMe } + val updated = if (currentReaction?.emoji == emoji) { + repository.clearReaction(session, message.chatId, message.id) + } else { + repository.setReaction(session, message.chatId, message.id, emoji) + } + + val current = state.value + if (current.selectedChat?.id == updated.chatId) { + state.value = current.copy( + messages = (current.messages.filterNot { it.id == updated.id } + updated).sortedBy { it.sentAt }, + replyTarget = current.replyTarget?.let { if (it.id == updated.id) updated else it }, + editTarget = current.editTarget?.let { if (it.id == updated.id) updated else it }, + forwardTarget = current.forwardTarget?.let { if (it.id == updated.id) updated else it } + ) + } + } + + fun openForwardPicker(message: MessageDto) { + state.value = state.value.copy(forwardTarget = message) + } + + fun closeForwardPicker() { + state.value = state.value.copy(forwardTarget = null) + } + + fun forwardTo(chat: ChatDto) = launchLoading(false) { + val session = requireSession() + val source = state.value.forwardTarget ?: return@launchLoading + val forwarded = repository.forwardMessage(session, source.chatId, source.id, chat.id) + val current = state.value + state.value = if (current.selectedChat?.id == chat.id) { + current.copy( + forwardTarget = null, + messages = (current.messages.filterNot { it.id == forwarded.id } + forwarded).sortedBy { it.sentAt } + ) + } else { + current.copy(forwardTarget = null) + } + loadChats() + } + + fun searchChatMessages(query: String) { + chatSearchJob?.cancel() + val trimmed = query.trim() + val session = state.value.session + val chat = state.value.selectedChat + if (trimmed.isBlank() || session == null || chat == null) { + state.value = state.value.copy(chatSearchResults = emptyList(), chatSearchLoading = false) + return + } + + val chatId = chat.id + chatSearchJob = viewModelScope.launch { + delay(250) + state.value = state.value.copy(chatSearchLoading = true) + try { + val results = withTimeout(ChatListTimeoutMs) { + repository.searchMessages(session, chatId, trimmed) + } + if (state.value.selectedChat?.id == chatId) { + state.value = state.value.copy( + chatSearchResults = results, + chatSearchLoading = false, + error = null + ) + } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + if (state.value.selectedChat?.id == chatId) { + state.value = state.value.copy(chatSearchLoading = false) + } + } + } + } + + fun cacheImage(attachment: AttachmentDto) { + val session = state.value.session ?: return + val key = attachment.id + if (state.value.cachedImagePaths.containsKey(key) || !imageCacheInFlight.add(key)) { + return + } + + viewModelScope.launch { + try { + val file = repository.cachedAttachmentFile(session, attachment) + val currentSession = state.value.session + if (currentSession?.serverUrl == session.serverUrl && currentSession.userName == session.userName) { + state.value = state.value.copy( + cachedImagePaths = state.value.cachedImagePaths + (key to file.absolutePath) + ) + } + } catch (_: Throwable) { + // The network-backed image loader remains as a fallback if the local cache cannot be populated. + } finally { + imageCacheInFlight.remove(key) + } + } + } + + private fun requireSession(): QMaxSession = state.value.session ?: error("No QMAX session") + + private fun saveCurrentDraft() { + val current = state.value + if (current.editTarget != null) { + return + } + val chatId = current.selectedChat?.id ?: return + viewModelScope.launch { + repository.saveDraft(chatId, current.composerText) + } + } + + private fun openPendingChatIfAvailable(chats: List) { + val chatId = pendingOpenChatId ?: return + val chat = chats.firstOrNull { it.id.equals(chatId, ignoreCase = true) } ?: return + pendingOpenChatId = null + if (state.value.selectedChat?.id != chat.id) { + openChat(chat) + } + } + + private suspend fun refreshPresence(chatId: String) { + val session = state.value.session ?: return + val presence = runCatching { + repository.chatPresence(session, chatId) + }.getOrNull() ?: return + if (state.value.selectedChat?.id != chatId) { + return + } + + val text = if (presence.isTyping) { + presence.statusText?.takeIf { it.isNotBlank() } ?: "\u043f\u0435\u0447\u0430\u0442\u0430\u0435\u0442..." + } else { + null + } + state.value = state.value.copy(chatPresenceText = text) + } + + private fun registerPushDevice(session: QMaxSession) { + val registrationKey = "${session.serverUrl}:${session.accessToken.take(12)}" + if (pushRegisteredForToken == registrationKey) { + return + } + + pushRegisteredForToken = registrationKey + viewModelScope.launch { + runCatching { + repository.registerCurrentPushDevice(session) + } + } + } + + private fun connectRealtime(session: QMaxSession) { + realtimeConnectJob?.cancel() + realtimeConnectJob = viewModelScope.launch { + runCatching { + realtime.connect( + session = session, + onChatListInvalidated = ::scheduleChatListRefresh, + onMessageCreated = ::handleRealtimeMessage, + onMessageUpdated = ::handleRealtimeMessageUpdated, + onMessageDeleted = ::handleRealtimeMessageDeleted + ) + realtime.joinChat(state.value.selectedChat?.id) + } + } + } + + private fun scheduleChatListRefresh() { + chatListRefreshJob?.cancel() + chatListRefreshJob = viewModelScope.launch { + delay(250) + refreshChats(showLoading = false) + } + } + + private fun handleRealtimeMessage(message: MessageDto) { + viewModelScope.launch { + val current = state.value + current.session?.let { repository.cacheRealtimeMessage(it, message) } + if (current.selectedChat?.id == message.chatId && + current.messages.none { it.id == message.id }) { + val updatedChats = normalizeChats( + current.chats.map { chat -> + if (chat.id == message.chatId) { + chat.copy( + lastMessagePreview = message.text ?: chat.lastMessagePreview, + lastMessageAt = message.sentAt, + unreadCount = 0 + ) + } else { + chat + } + }, + selectedChatId = message.chatId + ) + state.value = current.copy( + chats = updatedChats, + messages = (current.messages + message).sortedBy { it.sentAt } + ) + syncChatRead(message.chatId) + } + refreshChats(showLoading = false) + } + } + + private fun handleRealtimeMessageUpdated(message: MessageDto) { + viewModelScope.launch { + val current = state.value + current.session?.let { repository.cacheRealtimeMessage(it, message) } + if (current.selectedChat?.id == message.chatId) { + state.value = current.copy( + messages = (current.messages.filterNot { it.id == message.id } + message).sortedBy { it.sentAt }, + replyTarget = current.replyTarget?.let { if (it.id == message.id) message else it }, + editTarget = current.editTarget?.let { if (it.id == message.id) message else it }, + forwardTarget = current.forwardTarget?.let { if (it.id == message.id) message else it } + ) + } + refreshChats(showLoading = false) + } + } + + private fun handleRealtimeMessageDeleted(deleted: MessageDeletedDto) { + viewModelScope.launch { + val current = state.value + current.session?.let { repository.deleteCachedMessage(it, deleted.chatId, deleted.messageId) } + if (current.selectedChat?.id == deleted.chatId) { + state.value = current.copy( + messages = current.messages.filterNot { it.id == deleted.messageId }, + replyTarget = current.replyTarget?.takeIf { it.id != deleted.messageId }, + editTarget = current.editTarget?.takeIf { it.id != deleted.messageId }, + forwardTarget = current.forwardTarget?.takeIf { it.id != deleted.messageId } + ) + } + refreshChats(showLoading = false) + } + } + + private fun launchLoading(showLoading: Boolean = true, block: suspend () -> Unit) { + viewModelScope.launch { + if (showLoading) state.value = state.value.copy(loading = true, error = null) + try { + block() + state.value = state.value.copy(error = null) + } catch (error: Throwable) { + Log.w(LogTag, "launchLoading failed", error) + state.value = state.value.copy(error = error.message ?: "Ошибка") + } finally { + if (showLoading) state.value = state.value.copy(loading = false) + } + } + } + + private fun upsertMessage(messages: List, message: MessageDto): List { + return (messages.filterNot { it.id == message.id } + message).sortedBy { it.sentAt } + } + + private fun normalizeChats( + chats: List, + selectedChatId: String? = state.value.selectedChat?.id + ): List { + val normalized = chats.map { chat -> + val fingerprint = chatReadFingerprint(chat) + val isSelectedChat = selectedChatId != null && chat.id == selectedChatId + if (isSelectedChat) { + locallyReadChatFingerprints[chat.id] = fingerprint + } + + val shouldKeepRead = isSelectedChat || locallyReadChatFingerprints[chat.id] == fingerprint + if (shouldKeepRead && chat.unreadCount != 0) { + chat.copy(unreadCount = 0) + } else { + chat + } + } + return orderedChats(normalized) + } + + private fun rememberChatRead(chat: ChatDto) { + locallyReadChatFingerprints[chat.id] = chatReadFingerprint(chat) + } + + private fun chatReadFingerprint(chat: ChatDto): String { + return "${chat.lastMessageAt.orEmpty()}\n${chat.lastMessagePreview.orEmpty()}" + } + + private fun persistCurrentChats() { + val session = state.value.session ?: return + val chats = state.value.chats + viewModelScope.launch { + runCatching { + repository.cacheChats(session, chats) + } + } + } + + private fun syncChatRead(chatId: String) { + val session = state.value.session ?: return + viewModelScope.launch { + runCatching { + repository.markChatRead(session, chatId) + } + runCatching { + repository.cacheChats(session, state.value.chats) + } + } + } + + private fun orderedChats(chats: List): List { + return chats.sortedWith( + compareByDescending { it.isPinned } + .thenByDescending { it.unreadCount > 0 } + .thenByDescending { it.lastMessageAt.orEmpty() } + .thenBy { it.title.lowercase() } + ) + } + + class Factory(private val repository: QMaxRepository) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return QMaxViewModel(repository) as T + } + } + + override fun onCleared() { + super.onCleared() + viewModelScope.launch { + realtime.disconnect() + } + } + + private companion object { + const val LogTag = "QMAX" + const val ChatListTimeoutMs = 45_000L + const val MessageSyncTimeoutMs = 120_000L + const val AutoLoginDelayMs = 1_000L + } +} diff --git a/android/app/src/main/java/xyz/kusoft/qmax/ui/theme/QMaxTheme.kt b/android/app/src/main/java/xyz/kusoft/qmax/ui/theme/QMaxTheme.kt new file mode 100644 index 0000000..7f4cb1d --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/ui/theme/QMaxTheme.kt @@ -0,0 +1,35 @@ +package xyz.kusoft.qmax.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +val QMaxBlue = Color(0xFF3390EC) +val QMaxText = Color(0xFF17212B) +val QMaxMuted = Color(0xFF6C7883) +val QMaxBackground = Color(0xFFF3F7FA) +val QMaxIncoming = Color.White +val QMaxOutgoing = Color(0xFFE7F7C8) +val QMaxUnread = QMaxBlue + +private val Colors: ColorScheme = lightColorScheme( + primary = QMaxBlue, + onPrimary = Color.White, + background = QMaxBackground, + onBackground = QMaxText, + surface = Color.White, + onSurface = QMaxText, + surfaceVariant = Color(0xFFE9EEF2), + onSurfaceVariant = QMaxMuted +) + +@Composable +fun QMaxTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = Colors, + typography = MaterialTheme.typography, + content = content + ) +} diff --git a/android/app/src/main/res/drawable/ic_mark_read_24.xml b/android/app/src/main/res/drawable/ic_mark_read_24.xml new file mode 100644 index 0000000..a9c6d08 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_mark_read_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/android/app/src/main/res/mipmap-hdpi/ic_launcher.xml new file mode 100644 index 0000000..3542a6d --- /dev/null +++ b/android/app/src/main/res/mipmap-hdpi/ic_launcher.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ee089ba --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,3 @@ + + #3390EC + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..3a86657 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,7 @@ + + + diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..7b8050d --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..91fc868 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + id("com.android.application") version "9.2.1" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.3.10" apply false + id("com.google.gms.google-services") version "4.4.4" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..6aca716 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +org.gradle.workers.max=2 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.daemon.jvmargs=-Xmx2048m diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..eb84db6 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/key.properties b/android/key.properties new file mode 100644 index 0000000..18beee3 --- /dev/null +++ b/android/key.properties @@ -0,0 +1,4 @@ +storeFile=qmax-release.jks +storePassword=cc1ed02770207c5522192ea1f9eb3e4dc52040ac +keyAlias=qmax-release +keyPassword=cc1ed02770207c5522192ea1f9eb3e4dc52040ac diff --git a/android/qmax-release.jks b/android/qmax-release.jks new file mode 100644 index 0000000..23f8e47 Binary files /dev/null and b/android/qmax-release.jks differ diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..116a530 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "QMAX" +include(":app") diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..d73ac43 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,23 @@ +QMAX_DOMAIN=qmax.kusoft.xyz +QMAX_PUBLIC_BASE_URL=https://qmax.kusoft.xyz +QMAX_TLS_EMAIL=admin@kusoft.xyz + +# Generate with: openssl rand -base64 48 +QMAX_JWT_SECRET=change-me-to-a-long-random-secret-at-least-32-characters + +# Enter this once on the Android login screen to pair the device with your private bridge. +QMAX_PAIRING_CODE=change-me-pairing-code + +# Use international format if MAX accepts it in your flow, otherwise the local phone format you use in MAX Web. +QMAX_MAX_PHONE_NUMBER=79000000000 + +# Set false temporarily if you attach a visible browser/VNC workflow for CAPTCHA/debug. +QMAX_MAX_HEADLESS=true +QMAX_CORS_ALLOWED_ORIGINS= + +# Optional Firebase Cloud Messaging. Mount the service account JSON into the API container +# and set these values to enable real push notifications. +QMAX_PUSH_ENABLED=true +QMAX_PUSH_SHOW_PREVIEW=true +QMAX_FIREBASE_PROJECT_ID= +QMAX_FIREBASE_SERVICE_ACCOUNT_PATH= diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..c03b970 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,8 @@ +{ + email {$QMAX_TLS_EMAIL} +} + +{$QMAX_DOMAIN} { + encode zstd gzip + reverse_proxy qmax-api:8080 +} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..efc763d --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,58 @@ +services: + qmax-api: + build: + context: .. + dockerfile: Dockerfile.server + container_name: qmax-api + restart: unless-stopped + environment: + ASPNETCORE_ENVIRONMENT: Production + QMax__PublicBaseUrl: ${QMAX_PUBLIC_BASE_URL:-https://qmax.kusoft.xyz} + QMax__DatabasePath: /data/qmax.db + QMax__StoragePath: /data/storage + QMax__ReleasesPath: /data/releases + QMax__JwtSecret: ${QMAX_JWT_SECRET} + QMax__PairingCode: ${QMAX_PAIRING_CODE} + QMax__MaxPhoneNumber: ${QMAX_MAX_PHONE_NUMBER} + QMax__MaxMode: Worker + QMax__MaxWorkerBaseUrl: http://qmax-max-worker:3001 + QMax__CorsAllowedOrigins: ${QMAX_CORS_ALLOWED_ORIGINS:-} + QMax__PushEnabled: ${QMAX_PUSH_ENABLED:-true} + QMax__PushShowPreview: ${QMAX_PUSH_SHOW_PREVIEW:-true} + QMax__FirebaseProjectId: ${QMAX_FIREBASE_PROJECT_ID:-} + QMax__FirebaseServiceAccountPath: ${QMAX_FIREBASE_SERVICE_ACCOUNT_PATH:-} + volumes: + - qmax-data:/data + - ./releases:/data/releases:ro + - ./secrets:/run/secrets/qmax:ro + ports: + - "127.0.0.1:18080:8080" + depends_on: + - qmax-max-worker + 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 + +volumes: + qmax-data: + qmax-max-profile: + +networks: + qmax: + driver: bridge diff --git a/deploy/releases/android/latest.json b/deploy/releases/android/latest.json new file mode 100644 index 0000000..ad7ca3d --- /dev/null +++ b/deploy/releases/android/latest.json @@ -0,0 +1,14 @@ +{ + "slug": "qmax", + "name": "QMAX", + "version": "0.1.39", + "androidVersionCode": 40, + "channel": "stable", + "platform": "android", + "packageKind": "apk", + "downloadPath": "/api/app-updates/android/download/qmax-0.1.39-stable.apk", + "packageSizeBytes": 17730369, + "sha256": "fb1ba02daf4a5e99972794bd69965bef8219388c96344902b9d8a67ed2635803", + "notes": "Chat composer now follows the Android keyboard height like Telegram, and downloaded video, audio, and document attachments keep correct local cache extensions.", + "publishedAt": "2026-07-02T18:38:00.3130417Z" +} diff --git a/deploy/releases/android/qmax-0.1.0-stable.apk b/deploy/releases/android/qmax-0.1.0-stable.apk new file mode 100644 index 0000000..aa677ee Binary files /dev/null and b/deploy/releases/android/qmax-0.1.0-stable.apk differ diff --git a/deploy/releases/android/qmax-0.1.1-stable.apk b/deploy/releases/android/qmax-0.1.1-stable.apk new file mode 100644 index 0000000..049577b Binary files /dev/null and b/deploy/releases/android/qmax-0.1.1-stable.apk differ diff --git a/deploy/releases/android/qmax-0.1.2-stable.apk b/deploy/releases/android/qmax-0.1.2-stable.apk new file mode 100644 index 0000000..449b9f0 Binary files /dev/null and b/deploy/releases/android/qmax-0.1.2-stable.apk differ diff --git a/deploy/releases/android/qmax-0.1.3-stable.apk b/deploy/releases/android/qmax-0.1.3-stable.apk new file mode 100644 index 0000000..bad3fdc Binary files /dev/null and b/deploy/releases/android/qmax-0.1.3-stable.apk differ diff --git a/deploy/releases/android/qmax-0.1.35-stable.apk b/deploy/releases/android/qmax-0.1.35-stable.apk new file mode 100644 index 0000000..3757c02 Binary files /dev/null and b/deploy/releases/android/qmax-0.1.35-stable.apk differ diff --git a/deploy/releases/android/qmax-0.1.36-stable.apk b/deploy/releases/android/qmax-0.1.36-stable.apk new file mode 100644 index 0000000..f1caa04 Binary files /dev/null and b/deploy/releases/android/qmax-0.1.36-stable.apk differ diff --git a/deploy/releases/android/qmax-0.1.37-stable.apk b/deploy/releases/android/qmax-0.1.37-stable.apk new file mode 100644 index 0000000..c95040e Binary files /dev/null and b/deploy/releases/android/qmax-0.1.37-stable.apk differ diff --git a/deploy/releases/android/qmax-0.1.39-stable.apk b/deploy/releases/android/qmax-0.1.39-stable.apk new file mode 100644 index 0000000..cd98c8d Binary files /dev/null and b/deploy/releases/android/qmax-0.1.39-stable.apk differ diff --git a/deploy/releases/android/qmax-0.1.4-stable.apk b/deploy/releases/android/qmax-0.1.4-stable.apk new file mode 100644 index 0000000..ba2369c Binary files /dev/null and b/deploy/releases/android/qmax-0.1.4-stable.apk differ diff --git a/deploy/releases/android/qmax-0.1.5-stable.apk b/deploy/releases/android/qmax-0.1.5-stable.apk new file mode 100644 index 0000000..7a76b80 Binary files /dev/null and b/deploy/releases/android/qmax-0.1.5-stable.apk differ diff --git a/deploy/releases/android/qmax-0.1.6-stable.apk b/deploy/releases/android/qmax-0.1.6-stable.apk new file mode 100644 index 0000000..737406b Binary files /dev/null and b/deploy/releases/android/qmax-0.1.6-stable.apk differ diff --git a/deploy/releases/android/qmax-0.1.7-stable.apk b/deploy/releases/android/qmax-0.1.7-stable.apk new file mode 100644 index 0000000..c4f385e Binary files /dev/null and b/deploy/releases/android/qmax-0.1.7-stable.apk differ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..9a3fb40 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,36 @@ +# QMAX Architecture + +```mermaid +flowchart LR + A["Android app
Kotlin + Compose"] -->|HTTPS JSON + uploads| B["QMAX API
ASP.NET Core"] + 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 --> 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. + +## 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. +- `.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`. +- 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. +- 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/google-services.json b/google-services.json new file mode 100644 index 0000000..1fe306b --- /dev/null +++ b/google-services.json @@ -0,0 +1,54 @@ +{ + "project_info": { + "project_number": "471457572036", + "project_id": "qmax-29df6", + "storage_bucket": "qmax-29df6.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:471457572036:android:249a779d7a39b9c9359591", + "android_client_info": { + "package_name": "com.kusoft.qmax" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBYwLKxYG6nxBG6P11XHE-A4r6Pzz2umUk" + }, + { + "current_key": "AIzaSyB5QNIY1gBFdz92D_HFxX3wWMCn0HJSUc8" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:471457572036:android:585b1da178ce5344359591", + "android_client_info": { + "package_name": "ru.qmax.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBYwLKxYG6nxBG6P11XHE-A4r6Pzz2umUk" + }, + { + "current_key": "AIzaSyB5QNIY1gBFdz92D_HFxX3wWMCn0HJSUc8" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/qmax-0.1.42-awake.png b/qmax-0.1.42-awake.png new file mode 100644 index 0000000..ec79d3a Binary files /dev/null and b/qmax-0.1.42-awake.png differ diff --git a/qmax-0.1.42-awake.xml b/qmax-0.1.42-awake.xml new file mode 100644 index 0000000..e521eed --- /dev/null +++ b/qmax-0.1.42-awake.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/qmax-0.1.42-unlock2.png b/qmax-0.1.42-unlock2.png new file mode 100644 index 0000000..60bf1fa Binary files /dev/null and b/qmax-0.1.42-unlock2.png differ diff --git a/qmax-0.1.42-unlock2.xml b/qmax-0.1.42-unlock2.xml new file mode 100644 index 0000000..e521eed --- /dev/null +++ b/qmax-0.1.42-unlock2.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/qmax-0.1.42-unlock3.png b/qmax-0.1.42-unlock3.png new file mode 100644 index 0000000..9dcc3e3 Binary files /dev/null and b/qmax-0.1.42-unlock3.png differ diff --git a/qmax-0.1.42.png b/qmax-0.1.42.png new file mode 100644 index 0000000..9dcc3e3 Binary files /dev/null and b/qmax-0.1.42.png differ diff --git a/qmax-0.1.42.xml b/qmax-0.1.42.xml new file mode 100644 index 0000000..e521eed --- /dev/null +++ b/qmax-0.1.42.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/qmax-29df6-firebase-adminsdk-fbsvc-70d14870b0.json b/qmax-29df6-firebase-adminsdk-fbsvc-70d14870b0.json new file mode 100644 index 0000000..661b6cb --- /dev/null +++ b/qmax-29df6-firebase-adminsdk-fbsvc-70d14870b0.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "qmax-29df6", + "private_key_id": "70d14870b035f634c997cd7aa8a309628a8322fc", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC2PtpkPNq+Gxfr\n7qo8Ij3CtCLAYMMjthPbIEFRtM0SBoYCoanKlCEsDDGyfbWnvbONVNcDaGB4dkNu\np6ac+VCIihaCnR7i4uABpeYWW9mziG5Xdy1VYikrNyYFaqrEJkLeF501l+iYOToP\n3H0c+72/lfJvAHmCl0vwbfJisCJOizIRrTCie7t2KMAPpJvpGfb6X9RGSh75tTBP\nLR/aB6svO/jAqy+ak3TikIXbi3wmMEQPGIwomfyquV59c75+2Lz/1DgJSv1gobBP\nhK4xHjshEtMaqyulQPib4/8L5oLVU8nAYIBzeGggi7K+jsk+iNzaYTBJ0mb3oyge\nca4RdIKzAgMBAAECggEABhCfafiQewUhzas6pReqoIEoKujNdbHIhWFUayiuLmxj\nJ1FD+kblt7aMLNl8IyHwCMMLCMT/eOLOqa8Qn2a4pGKcDyq1bW2JcOSZfKL/4ram\nnWiM7KuAnTyPmlyLZE21qfh1NeRhTDeTVBXNYBqMnQSbddA5OvxX/Z9bEqez7JMs\n28XAtYyxbnafngjsxBFozchL6oOtxtzBmROZklDatxalb4LUEKwFNFSmcKWGhNEF\nUuf4pTO9Fh3AfRkKdNj5Tx790KmMYdKA7OxQ/rsMwyJTi0oMxdKzCMEmcUrRYcrU\niJRkituWKKPtP6NsysdJ9Mh7qHxUG3Jg+epiNH5qsQKBgQDuls3q50sy12vW4dyP\nVlKu0+QmaMKXcywaXHStHYF34kyH1QZOHlTeJzxWFBKJitKHawXfy7IDQlyVrhxs\nCSkBpx26ewMjWyPS6pLMwrP/17s6oDeh8toZyq7I9vUZ170Jeekp10yixA7GiJgb\nq2hO614hZ9Js5O/1trr0G8vjmQKBgQDDi3fOcLmh9IjJLMdeRNG+U6lf1OQbH2pl\nbqXTlScfAD7YE6OFozQoHv/RuaA/6x6SRrkqwgpU+qIzZ/ykSOpI5HlYJy5CF3a8\nAlHJmSKS1BJheTU0Br2hBgGKxr03JqVij128WMf/jcbEHmi34Up9QfI4rD4eBFKp\nvkvIDyyIKwKBgQCUjTbW7H4IwNI3L9fpM0E1815Zf96w95fdLfXDl9x1rWQjKsLL\nPt1umJVbrxG/q7zbbgpxRl2m49nLpGWz6pwqmEfNRSw2BlguybjvXsc+I69CmGEr\nJ48egfED1afUGFxuGwbO82uW6GWevYufpsDCao/oUsFU5dJ2dfi/ZtMy+QKBgQCO\nV4W8lr3qMLEpkBkIfBwZ6ZiPk88AF1xBvcukOxyhKnKUo6cS7nxXAEnEBtWp8aLt\nY+ICSAFxXoiX1whHJnS03uudydeYcLATp7SVhY1vSEShA7RAzO9YuaCBvq2Z6d3V\n4vqe0hmz4yZfOKuNvseUal7B9k+5Vfg+a7GKTdFixQKBgFzaUXdkINiU3qO1qCPe\ndgLJCg/6QPKScnBxejirIsgRUMAjPzssqEh4KesksCCMB+KBnz6NMCbuNGM8d1XB\nJ4kv+e2JsLPlGyFWY1HbHiigGWXt5NTDXzSrZVi3v59dGx1DgKFsbeTC14FjKBgP\ne7TM6Oyi4+/hUgpWNhAP0fuO\n-----END PRIVATE KEY-----\n", + "client_email": "firebase-adminsdk-fbsvc@qmax-29df6.iam.gserviceaccount.com", + "client_id": "109102072285119842018", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40qmax-29df6.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/qmax-after-install.png b/qmax-after-install.png new file mode 100644 index 0000000..57c881e Binary files /dev/null and b/qmax-after-install.png differ diff --git a/qmax-after-install.xml b/qmax-after-install.xml new file mode 100644 index 0000000..62b2655 --- /dev/null +++ b/qmax-after-install.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/qmax-chat-ime.xml b/qmax-chat-ime.xml new file mode 100644 index 0000000..3ec6ccd --- /dev/null +++ b/qmax-chat-ime.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/qmax-chat.xml b/qmax-chat.xml new file mode 100644 index 0000000..649c275 --- /dev/null +++ b/qmax-chat.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/qmax-share-chat.xml b/qmax-share-chat.xml new file mode 100644 index 0000000..8b9cd43 --- /dev/null +++ b/qmax-share-chat.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/qmax-share-list.xml b/qmax-share-list.xml new file mode 100644 index 0000000..1a832e9 --- /dev/null +++ b/qmax-share-list.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/qmax-share-list2.xml b/qmax-share-list2.xml new file mode 100644 index 0000000..b17a61a --- /dev/null +++ b/qmax-share-list2.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/qmax-share-menu.xml b/qmax-share-menu.xml new file mode 100644 index 0000000..bb0bafa --- /dev/null +++ b/qmax-share-menu.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/qmax-ui.xml b/qmax-ui.xml new file mode 100644 index 0000000..3b0063e --- /dev/null +++ b/qmax-ui.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/deploy-rpi.ps1 b/scripts/deploy-rpi.ps1 new file mode 100644 index 0000000..5b73d5b --- /dev/null +++ b/scripts/deploy-rpi.ps1 @@ -0,0 +1,56 @@ +param( + [string]$HostName = "195.216.241.165", + [int]$Port = 2222, + [string]$User = "sevenhill", + [string]$RemoteDir = "/home/sevenhill/qmax" +) + +$ErrorActionPreference = "Stop" + +Write-Host "This script prepares QMAX files for Raspberry Pi deployment." +Write-Host "It intentionally does not contain or echo SSH passwords." +Write-Host "Prerequisites on the Pi: Docker Engine, Docker Compose plugin, DNS A record for qmax.kusoft.xyz." + +$archive = Join-Path $env:TEMP "qmax-deploy.tar.gz" +if (Test-Path $archive) { + Remove-Item -LiteralPath $archive -Force +} + +$root = Resolve-Path "$PSScriptRoot\.." +$staging = Join-Path $env:TEMP "qmax-deploy-staging" +if (Test-Path $staging) { + Remove-Item -LiteralPath $staging -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $staging | Out-Null + +foreach ($item in @("server", "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" +)) { + $target = Join-Path $staging $path + if (Test-Path $target) { + Remove-Item -LiteralPath $target -Recurse -Force + } +} + +Push-Location $staging +try { + tar -czf $archive server worker deploy Dockerfile.server QMax.slnx +} +finally { + Pop-Location +} + +Write-Host "Archive created: $archive" +Write-Host "Upload:" +Write-Host " scp -P $Port $archive ${User}@${HostName}:/tmp/qmax-deploy.tar.gz" +Write-Host "Remote install:" +Write-Host " ssh -p $Port ${User}@${HostName}" +Write-Host " rm -rf $RemoteDir && mkdir -p $RemoteDir && tar -xzf /tmp/qmax-deploy.tar.gz -C $RemoteDir" +Write-Host " cd $RemoteDir/deploy && cp .env.example .env && nano .env" +Write-Host " docker compose up -d --build" diff --git a/scripts/package-android-release.ps1 b/scripts/package-android-release.ps1 new file mode 100644 index 0000000..66df1e7 --- /dev/null +++ b/scripts/package-android-release.ps1 @@ -0,0 +1,50 @@ +param( + [string]$Version = "0.1.0", + [int]$VersionCode = 1, + [string]$Channel = "stable" +) + +$ErrorActionPreference = "Stop" +$root = Resolve-Path "$PSScriptRoot\.." +$apk = Join-Path $root "android\app\build\outputs\apk\release\app-release.apk" +$releaseDir = Join-Path $root "deploy\releases\android" + +Push-Location (Join-Path $root "android") +try { + .\gradlew.bat :app:assembleRelease --console=plain --no-daemon +} +finally { + Pop-Location +} + +if (!(Test-Path $apk)) { + throw "Release APK was not produced: $apk" +} + +New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null +$targetName = "qmax-$Version-$Channel.apk" +$target = Join-Path $releaseDir $targetName +Copy-Item -LiteralPath $apk -Destination $target -Force + +$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $target).Hash.ToLowerInvariant() +$size = (Get-Item -LiteralPath $target).Length +$manifest = [ordered]@{ + slug = "qmax" + name = "QMAX" + version = $Version + androidVersionCode = $VersionCode + channel = $Channel + platform = "android" + packageKind = "apk" + downloadPath = "/api/app-updates/android/download/$targetName" + packageSizeBytes = $size + sha256 = $hash + notes = "QMAX $Version" + publishedAt = (Get-Date).ToUniversalTime().ToString("O") +} + +$manifestJson = $manifest | ConvertTo-Json -Depth 4 +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +[System.IO.File]::WriteAllText((Join-Path $releaseDir "latest.json"), $manifestJson, $utf8NoBom) +Write-Host "Packaged $targetName" +Write-Host "SHA-256 $hash" diff --git a/scripts/smoke.ps1 b/scripts/smoke.ps1 new file mode 100644 index 0000000..38be273 --- /dev/null +++ b/scripts/smoke.ps1 @@ -0,0 +1,17 @@ +param( + [string]$ServerUrl = "http://localhost:8080", + [string]$PairingCode = "qmax-local-dev" +) + +$ErrorActionPreference = "Stop" + +dotnet test "$PSScriptRoot\..\QMax.slnx" +Push-Location "$PSScriptRoot\..\android" +try { + .\gradlew.bat :app:assembleDebug :app:assembleRelease --console=plain --no-daemon +} +finally { + Pop-Location +} + +Write-Host "Smoke build checks completed." diff --git a/server/QMax.Api/Configuration/QMaxOptions.cs b/server/QMax.Api/Configuration/QMaxOptions.cs new file mode 100644 index 0000000..fdf21cf --- /dev/null +++ b/server/QMax.Api/Configuration/QMaxOptions.cs @@ -0,0 +1,26 @@ +namespace QMax.Api.Configuration; + +public sealed class QMaxOptions +{ + public string PublicBaseUrl { get; set; } = "https://qmax.kusoft.xyz"; + public string DatabasePath { get; set; } = "data/qmax.db"; + public string StoragePath { get; set; } = "data/storage"; + public string JwtIssuer { get; set; } = "QMAX"; + public string JwtAudience { get; set; } = "QMAX.Android"; + public string JwtSecret { get; set; } = ""; + public string PairingCode { get; set; } = ""; + public string MaxPhoneNumber { get; set; } = ""; + public string MaxMode { get; set; } = "Worker"; + public string MaxWorkerBaseUrl { get; set; } = "http://qmax-max-worker:3001"; + public string MaxUserDataPath { get; set; } = "data/max-profile"; + public bool MaxHeadless { get; set; } = true; + public int MaxPollIntervalSeconds { get; set; } = 6; + public long MaxUploadBytes { get; set; } = 25L * 1024 * 1024; + public string CorsAllowedOrigins { get; set; } = ""; + public string ReleasesPath { get; set; } = "data/releases"; + public bool PushEnabled { get; set; } = true; + public bool PushShowPreview { get; set; } = true; + public string FirebaseProjectId { get; set; } = ""; + public string FirebaseServiceAccountPath { get; set; } = ""; + public string FirebaseServiceAccountJson { get; set; } = ""; +} diff --git a/server/QMax.Api/Contracts/AppUpdateContracts.cs b/server/QMax.Api/Contracts/AppUpdateContracts.cs new file mode 100644 index 0000000..c8f3872 --- /dev/null +++ b/server/QMax.Api/Contracts/AppUpdateContracts.cs @@ -0,0 +1,15 @@ +namespace QMax.Api.Contracts; + +public sealed record AppUpdateManifestDto( + string Slug, + string Name, + string Version, + int AndroidVersionCode, + string Channel, + string Platform, + string PackageKind, + string DownloadPath, + long PackageSizeBytes, + string Sha256, + string Notes, + DateTimeOffset PublishedAt); diff --git a/server/QMax.Api/Contracts/AuthContracts.cs b/server/QMax.Api/Contracts/AuthContracts.cs new file mode 100644 index 0000000..5d48bea --- /dev/null +++ b/server/QMax.Api/Contracts/AuthContracts.cs @@ -0,0 +1,7 @@ +namespace QMax.Api.Contracts; + +public sealed record DeviceLoginRequest(string PairingCode, string DeviceName); +public sealed record RefreshTokenRequest(string RefreshToken); +public sealed record AuthResponse(string AccessToken, string RefreshToken, DateTimeOffset ExpiresAt, UserDto User); +public sealed record UserDto(Guid Id, string DisplayName, string? PhoneNumber, string? AvatarPath); +public sealed record SessionDto(Guid Id, string DeviceName, DateTimeOffset CreatedAt, DateTimeOffset LastSeenAt, DateTimeOffset ExpiresAt, bool IsCurrent); diff --git a/server/QMax.Api/Contracts/ChatContracts.cs b/server/QMax.Api/Contracts/ChatContracts.cs new file mode 100644 index 0000000..c6c7082 --- /dev/null +++ b/server/QMax.Api/Contracts/ChatContracts.cs @@ -0,0 +1,53 @@ +using QMax.Api.Data.Entities; + +namespace QMax.Api.Contracts; + +public sealed record ChatDto( + Guid Id, + string? ExternalId, + ChatKind Kind, + string Title, + string? AvatarUrl, + string? LastMessagePreview, + DateTimeOffset? LastMessageAt, + int UnreadCount, + bool IsPinned, + bool IsMuted, + bool IsArchived); + +public sealed record MessageDto( + Guid Id, + Guid ChatId, + string? ExternalId, + string? SenderName, + MessageDirection Direction, + string? Text, + DateTimeOffset SentAt, + DateTimeOffset? EditedAt, + DateTimeOffset? DeletedAt, + Guid? ReplyToMessageId, + string? ForwardedFrom, + MessageDeliveryState DeliveryState, + string? Error, + IReadOnlyList Reactions, + IReadOnlyList Attachments); + +public sealed record ReactionDto(string Emoji, int Count, bool ReactedByMe); + +public sealed record AttachmentDto( + Guid Id, + string FileName, + string ContentType, + long FileSizeBytes, + string DownloadPath, + AttachmentKind Kind, + int SortOrder); + +public sealed record SendMessageRequest(string Text, Guid? ReplyToMessageId = null); +public sealed record EditMessageRequest(string Text); +public sealed record ForwardMessageRequest(Guid TargetChatId); +public sealed record SetReactionRequest(string Emoji); +public sealed record MessageDeletedDto(Guid ChatId, Guid MessageId); +public sealed record CreateDirectChatRequest(string ExternalChatId, string Title, string? AvatarUrl = null); + +public sealed record ChatPresenceDto(bool IsTyping, string? StatusText, DateTimeOffset UpdatedAt); diff --git a/server/QMax.Api/Contracts/MaxContracts.cs b/server/QMax.Api/Contracts/MaxContracts.cs new file mode 100644 index 0000000..a1475af --- /dev/null +++ b/server/QMax.Api/Contracts/MaxContracts.cs @@ -0,0 +1,20 @@ +namespace QMax.Api.Contracts; + +public sealed record BeginMaxLoginRequest(string? PhoneNumber); +public sealed record SubmitMaxCodeRequest(string Code); +public sealed record MaxBridgeStatusDto( + string Mode, + bool IsAuthorized, + string? LoginStage, + string Status, + string? Url, + string? Title, + string? LastError, + DateTimeOffset UpdatedAt); + +public sealed record MaxBrowserSnapshotDto( + string? Url, + string? Title, + string BodyText, + string ScreenshotPngBase64, + DateTimeOffset CapturedAt); diff --git a/server/QMax.Api/Controllers/AdminController.cs b/server/QMax.Api/Controllers/AdminController.cs new file mode 100644 index 0000000..2a4d2be --- /dev/null +++ b/server/QMax.Api/Controllers/AdminController.cs @@ -0,0 +1,481 @@ +using System.Text; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using QMax.Api.Configuration; +using QMax.Api.Data; +using QMax.Api.Infrastructure.Max; +using QMax.Api.Infrastructure.Storage; + +namespace QMax.Api.Controllers; + +[ApiController] +[AllowAnonymous] +[Route("admin")] +public sealed class AdminController( + QMaxDbContext db, + IOptions options, + IAttachmentStorageService storage, + IMaxBridgeClient maxBridge) : ControllerBase +{ + private readonly QMaxOptions _options = options.Value; + + [HttpGet] + public async Task Index([FromQuery] string? pairingCode, CancellationToken cancellationToken) + { + if (!IsPairingCodeValid(pairingCode)) + { + return Content(AdminShell("

QMAX Admin

Open this page with ?pairingCode=....

"), "text/html", Encoding.UTF8); + } + + var safePairingCode = pairingCode ?? ""; + var status = await BuildStatusAsync(cancellationToken); + var encodedPairingCode = Uri.EscapeDataString(safePairingCode); + var latestChats = (await db.Chats.AsNoTracking().ToArrayAsync(cancellationToken)) + .OrderByDescending(x => x.LastMessageAt ?? x.UpdatedAt) + .Take(20) + .Select(x => new + { + x.Id, + x.Title, + x.Kind, + x.UnreadCount, + LastMessageAt = x.LastMessageAt ?? x.UpdatedAt, + x.LastMessagePreview + }) + .ToArray(); + var latestAttachments = (await db.MessageAttachments + .AsNoTracking() + .Include(x => x.Message) + .ThenInclude(x => x!.Chat) + .ToArrayAsync(cancellationToken)) + .OrderByDescending(x => x.CreatedAt) + .Take(20) + .Select(x => new + { + x.Id, + ChatTitle = x.Message!.Chat!.Title, + x.OriginalFileName, + x.Kind, + x.ContentType, + x.FileSizeBytes, + x.CreatedAt + }) + .ToArray(); + var latestUsers = (await db.Users + .AsNoTracking() + .Include(x => x.Sessions) + .ToArrayAsync(cancellationToken)) + .OrderByDescending(x => x.UpdatedAt) + .Take(20) + .Select(x => new + { + x.Id, + x.DisplayName, + x.PhoneNumber, + x.CreatedAt, + x.UpdatedAt, + Sessions = x.Sessions.Count, + ActiveSessions = x.Sessions.Count(session => session.RevokedAt == null && session.ExpiresAt > DateTimeOffset.UtcNow) + }) + .ToArray(); + + var rows = string.Join("", latestChats.Select(chat => $$""" + + {{Html(chat.Title)}} + {{Html(chat.Kind.ToString())}} + {{chat.UnreadCount}} + {{Html(chat.LastMessageAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}} + {{Html(chat.LastMessagePreview)}} + + """)); + var attachmentRows = string.Join("", latestAttachments.Select(attachment => $$""" + + {{Html(attachment.OriginalFileName)}} + {{Html(attachment.ChatTitle)}} + {{Html(attachment.Kind.ToString())}} + {{Html(attachment.ContentType)}} + {{FormatBytes(attachment.FileSizeBytes)}} + {{Html(attachment.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}} + + """)); + var userRows = string.Join("", latestUsers.Select(user => $$""" + + {{Html(user.DisplayName)}} + {{Html(user.PhoneNumber)}} + {{user.ActiveSessions}} / {{user.Sessions}} + {{Html(user.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}} + {{Html(user.UpdatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}} + {{user.Id}} + + """)); + + var html = $$""" +

QMAX Admin

+ +
+
{{status.Users}}Users
+
{{status.Chats}}Chats
+
{{status.Messages}}Messages
+
{{status.Attachments}}Attachments
+
{{FormatBytes(status.AttachmentBytes)}}Media storage
+
{{Html(status.MaxStatus)}}MAX
+
{{status.PushDevices}}Push devices
+
+
+

Latest chats

+ + + {{rows}} +
TitleKindUnreadUpdatedPreview
+
+
+

Users

+ + + {{userRows}} +
NamePhoneSessionsCreatedUpdatedId
+
+
+

Latest attachments

+ + + {{attachmentRows}} +
FileChatKindContent typeSizeCreated
+
+ """; + + return Content(AdminShell(html), "text/html", Encoding.UTF8); + } + + [HttpGet("api/status")] + public async Task Status([FromQuery] string? pairingCode, CancellationToken cancellationToken) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + return Ok(await BuildStatusAsync(cancellationToken)); + } + + [HttpGet("api/chats")] + public async Task Chats([FromQuery] string? pairingCode, [FromQuery] string? q, [FromQuery] int take = 100, CancellationToken cancellationToken = default) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + + var query = db.Chats.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(q)) + { + query = query.Where(x => x.Title.Contains(q) || (x.LastMessagePreview != null && x.LastMessagePreview.Contains(q))); + } + + var chats = (await query + .ToArrayAsync(cancellationToken)) + .OrderByDescending(x => x.LastMessageAt ?? x.UpdatedAt) + .Take(Math.Clamp(take, 1, 500)) + .Select(x => new + { + x.Id, + x.ExternalId, + x.Kind, + x.Title, + x.UnreadCount, + x.IsPinned, + x.IsMuted, + x.IsArchived, + x.LastMessageAt, + x.LastMessagePreview, + x.UpdatedAt + }) + .ToArray(); + + return Ok(chats); + } + + [HttpGet("api/messages")] + public async Task Messages([FromQuery] string? pairingCode, [FromQuery] Guid? chatId, [FromQuery] string? q, [FromQuery] int take = 100, CancellationToken cancellationToken = default) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + + var query = db.Messages.AsNoTracking().Include(x => x.Chat).Include(x => x.Attachments).Where(x => x.DeletedAt == null); + if (chatId is not null) + { + query = query.Where(x => x.ChatId == chatId); + } + + if (!string.IsNullOrWhiteSpace(q)) + { + query = query.Where(x => x.Text != null && x.Text.Contains(q)); + } + + var messages = (await query + .ToArrayAsync(cancellationToken)) + .OrderByDescending(x => x.SentAt) + .Take(Math.Clamp(take, 1, 500)) + .Select(x => new + { + x.Id, + x.ChatId, + ChatTitle = x.Chat!.Title, + x.Direction, + x.SenderName, + x.Text, + x.SentAt, + x.DeliveryState, + x.Error, + Attachments = x.Attachments.Count + }) + .ToArray(); + + return Ok(messages); + } + + [HttpGet("api/users")] + public async Task Users([FromQuery] string? pairingCode, [FromQuery] int take = 100, CancellationToken cancellationToken = default) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + + var users = (await db.Users + .AsNoTracking() + .Include(x => x.Sessions) + .ToArrayAsync(cancellationToken)) + .OrderByDescending(x => x.UpdatedAt) + .Take(Math.Clamp(take, 1, 500)) + .Select(x => new + { + x.Id, + x.DisplayName, + x.PhoneNumber, + x.AvatarPath, + x.CreatedAt, + x.UpdatedAt, + Sessions = x.Sessions + .OrderByDescending(session => session.LastSeenAt) + .Select(session => new + { + session.Id, + session.DeviceName, + session.CreatedAt, + session.LastSeenAt, + session.ExpiresAt, + IsActive = session.RevokedAt == null && session.ExpiresAt > DateTimeOffset.UtcNow, + session.RevokedAt + }) + .ToArray() + }) + .ToArray(); + + return Ok(users); + } + + [HttpGet("api/attachments")] + public async Task Attachments([FromQuery] string? pairingCode, [FromQuery] Guid? chatId, [FromQuery] int take = 100, CancellationToken cancellationToken = default) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + + var query = db.MessageAttachments.AsNoTracking().Include(x => x.Message).ThenInclude(x => x!.Chat).AsQueryable(); + if (chatId is not null) + { + query = query.Where(x => x.Message!.ChatId == chatId); + } + + var attachments = (await query + .ToArrayAsync(cancellationToken)) + .OrderByDescending(x => x.CreatedAt) + .Take(Math.Clamp(take, 1, 500)) + .Select(x => new + { + x.Id, + x.MessageId, + ChatId = x.Message!.ChatId, + ChatTitle = x.Message.Chat!.Title, + FileName = x.OriginalFileName, + x.ContentType, + x.FileSizeBytes, + x.Kind, + x.RemoteUrl, + x.CreatedAt, + ContentPath = $"/admin/api/attachments/{x.Id}/content" + }) + .ToArray(); + + return Ok(attachments); + } + + [HttpGet("api/attachments/{id:guid}/content")] + public async Task AttachmentContent(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + + var attachment = await db.MessageAttachments.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (attachment is null) + { + return NotFound(); + } + + var path = storage.GetPath(attachment.StorageFileName); + return System.IO.File.Exists(path) + ? PhysicalFile(path, attachment.ContentType, attachment.OriginalFileName, enableRangeProcessing: true) + : NotFound(); + } + + [HttpDelete("api/attachments/{id:guid}")] + public async Task DeleteAttachment(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + + var attachment = await db.MessageAttachments.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (attachment is null) + { + return NotFound(); + } + + var path = storage.GetPath(attachment.StorageFileName); + db.MessageAttachments.Remove(attachment); + await db.SaveChangesAsync(cancellationToken); + if (System.IO.File.Exists(path)) + { + System.IO.File.Delete(path); + } + + return NoContent(); + } + + [HttpDelete("api/users/{id:guid}")] + public async Task DeleteUser(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + + var user = await db.Users.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (user is null) + { + return NotFound(); + } + + await db.UserSessions.Where(x => x.UserId == id).ExecuteDeleteAsync(cancellationToken); + await db.PushDevices.Where(x => x.UserId == id).ExecuteDeleteAsync(cancellationToken); + db.Users.Remove(user); + await db.SaveChangesAsync(cancellationToken); + + return NoContent(); + } + + [HttpDelete("api/chats/{id:guid}")] + public async Task DeleteChat(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + + var chat = await db.Chats + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + var files = chat.Messages + .SelectMany(x => x.Attachments) + .Select(x => storage.GetPath(x.StorageFileName)) + .Where(System.IO.File.Exists) + .ToArray(); + + db.Chats.Remove(chat); + await db.SaveChangesAsync(cancellationToken); + foreach (var file in files) + { + System.IO.File.Delete(file); + } + + return NoContent(); + } + + private async Task BuildStatusAsync(CancellationToken cancellationToken) + { + var maxStatus = await maxBridge.GetStatusAsync(cancellationToken); + return new AdminStatusDto( + "QMAX Bridge", + DateTimeOffset.UtcNow, + await db.Users.CountAsync(cancellationToken), + await db.Chats.CountAsync(cancellationToken), + await db.Messages.CountAsync(cancellationToken), + await db.MessageAttachments.CountAsync(cancellationToken), + await db.MessageAttachments.Select(x => (long?)x.FileSizeBytes).SumAsync(cancellationToken) ?? 0, + await db.PushDevices.CountAsync(cancellationToken), + maxStatus.IsAuthorized, + maxStatus.Status, + maxStatus.LastError); + } + + private bool IsPairingCodeValid(string? pairingCode) + { + return !string.IsNullOrWhiteSpace(_options.PairingCode) && + string.Equals(pairingCode, _options.PairingCode, StringComparison.Ordinal); + } + + private static string AdminShell(string body) + { + return $$""" + + + + + + QMAX Admin + + +
{{body}}
+ + """; + } + + private static string Html(string? value) + { + return System.Net.WebUtility.HtmlEncode(value ?? ""); + } + + private static string FormatBytes(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + var kb = bytes / 1024.0; + if (kb < 1024) return $"{kb:0.0} KB"; + var mb = kb / 1024.0; + if (mb < 1024) return $"{mb:0.0} MB"; + return $"{mb / 1024.0:0.0} GB"; + } + + private sealed record AdminStatusDto( + string Name, + DateTimeOffset ServerTime, + int Users, + int Chats, + int Messages, + int Attachments, + long AttachmentBytes, + int PushDevices, + bool MaxAuthorized, + string MaxStatus, + string? MaxLastError); +} diff --git a/server/QMax.Api/Controllers/AdminMaxController.cs b/server/QMax.Api/Controllers/AdminMaxController.cs new file mode 100644 index 0000000..76fa2b8 --- /dev/null +++ b/server/QMax.Api/Controllers/AdminMaxController.cs @@ -0,0 +1,236 @@ +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using QMax.Api.Configuration; +using QMax.Api.Infrastructure.Max; + +namespace QMax.Api.Controllers; + +[ApiController] +[AllowAnonymous] +[Route("admin/max")] +public sealed class AdminMaxController( + IHttpClientFactory httpClientFactory, + IOptions options) : ControllerBase +{ + private readonly QMaxOptions _options = options.Value; + + [HttpGet] + public async Task Index([FromQuery] string pairingCode, CancellationToken cancellationToken) + { + if (!IsPairingCodeValid(pairingCode)) + { + return Content(AdminShell("

QMAX MAX Admin

Open this page with ?pairingCode=....

"), "text/html", Encoding.UTF8); + } + + var snapshot = await WorkerGetAsync("snapshot", cancellationToken); + var status = await WorkerGetAsync("status", cancellationToken); + var screenshot = string.IsNullOrWhiteSpace(snapshot?.ScreenshotPngBase64) + ? "

No screenshot yet.

" + : $""; + var encodedPairingCode = Uri.EscapeDataString(pairingCode); + var jsAuthorized = status?.IsAuthorized == true ? "true" : "false"; + var jsStage = JsonSerializer.Serialize(status?.LoginStage ?? ""); + + var html = $$""" +

QMAX MAX Admin

+

Status: {{Html(status?.Status)}} | Stage: {{Html(status?.LoginStage)}} | Authorized: {{status?.IsAuthorized}} | URL: {{Html(snapshot?.Url)}}

+
+ + +
+
+ + + +
+
+ + + + +
+
+ + + +
+
+ + + +
+ +

Click directly on the screenshot to control the remote MAX browser. QMAX will forward the click and refresh the image.

+
{{screenshot}}
+ + """; + + return Content(AdminShell(html), "text/html", Encoding.UTF8); + } + + [HttpGet("inspect/{kind}")] + public async Task Inspect([FromRoute] string kind, [FromQuery] string pairingCode, CancellationToken cancellationToken) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + + var workerPath = kind.ToLowerInvariant() switch + { + "dom" => "inspect/dom", + "storage" => "inspect/storage", + "indexeddb-sample" => "inspect/indexeddb/sample", + "network" => "inspect/network", + _ => null + }; + if (workerPath is null) return NotFound(); + + return Content(await WorkerGetRawAsync(workerPath, cancellationToken), "application/json", Encoding.UTF8); + } + + [HttpGet("updates")] + public async Task Updates([FromQuery] string pairingCode, CancellationToken cancellationToken) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + return Content(await WorkerGetRawAsync("updates", cancellationToken), "application/json", Encoding.UTF8); + } + + [HttpPost("login/start")] + public async Task StartLogin([FromForm] string pairingCode, CancellationToken cancellationToken) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + await WorkerPostAsync("login/start", new { phoneNumber = _options.MaxPhoneNumber }, cancellationToken); + return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}"); + } + + [HttpPost("login/code")] + public async Task Code([FromForm] string pairingCode, [FromForm] string code, CancellationToken cancellationToken) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + await WorkerPostAsync("login/code", new { code, waitMs = 60000 }, cancellationToken); + return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}"); + } + + [HttpPost("click")] + public async Task Click([FromForm] string pairingCode, [FromForm] int x, [FromForm] int y, CancellationToken cancellationToken) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + await WorkerPostAsync("browser/click", new { x, y }, cancellationToken); + return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}"); + } + + [HttpPost("type")] + public async Task Type([FromForm] string pairingCode, [FromForm] string text, CancellationToken cancellationToken) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + await WorkerPostAsync("browser/type", new { text }, cancellationToken); + return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}"); + } + + [HttpPost("press")] + public async Task Press([FromForm] string pairingCode, [FromForm] string key, CancellationToken cancellationToken) + { + if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); + await WorkerPostAsync("browser/press", new { key = string.IsNullOrWhiteSpace(key) ? "Enter" : key }, cancellationToken); + return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}"); + } + + private async Task WorkerGetAsync(string path, CancellationToken cancellationToken) + { + var client = httpClientFactory.CreateClient(); + client.BaseAddress = new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/"); + return await client.GetFromJsonAsync(path, cancellationToken); + } + + private async Task WorkerGetRawAsync(string path, CancellationToken cancellationToken) + { + var client = httpClientFactory.CreateClient(); + client.BaseAddress = new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/"); + return await client.GetStringAsync(path, cancellationToken); + } + + private async Task WorkerPostAsync(string path, object body, CancellationToken cancellationToken) + { + var client = httpClientFactory.CreateClient(); + client.BaseAddress = new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/"); + using var response = await client.PostAsJsonAsync(path, body, cancellationToken); + response.EnsureSuccessStatusCode(); + } + + private bool IsPairingCodeValid(string pairingCode) + { + return !string.IsNullOrWhiteSpace(_options.PairingCode) && + string.Equals(pairingCode, _options.PairingCode, StringComparison.Ordinal); + } + + private static string AdminShell(string body) + { + return $$""" + + + + + + QMAX MAX Admin + + +
{{body}}
+ + """; + } + + private static string Html(string? value) + { + return System.Net.WebUtility.HtmlEncode(value ?? ""); + } +} diff --git a/server/QMax.Api/Controllers/AppUpdatesController.cs b/server/QMax.Api/Controllers/AppUpdatesController.cs new file mode 100644 index 0000000..01c1540 --- /dev/null +++ b/server/QMax.Api/Controllers/AppUpdatesController.cs @@ -0,0 +1,79 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using QMax.Api.Configuration; +using QMax.Api.Contracts; + +namespace QMax.Api.Controllers; + +[ApiController] +[Route("api/app-updates")] +public sealed class AppUpdatesController(IOptions options) : ControllerBase +{ + private readonly QMaxOptions _options = options.Value; + + [AllowAnonymous] + [HttpGet("android/latest")] + public async Task> Latest(CancellationToken cancellationToken) + { + var manifestPath = Path.Combine(_options.ReleasesPath, "android", "latest.json"); + if (System.IO.File.Exists(manifestPath)) + { + return PhysicalFile(manifestPath, "application/json"); + } + + var androidReleases = new DirectoryInfo(Path.Combine(_options.ReleasesPath, "android")); + if (!androidReleases.Exists) + { + return NotFound(); + } + + var apk = androidReleases + .GetFiles("*.apk") + .OrderByDescending(x => x.LastWriteTimeUtc) + .FirstOrDefault(); + + if (apk is null) + { + return NotFound(); + } + + var manifest = new AppUpdateManifestDto( + "qmax", + "QMAX", + "0.1.0", + 1, + "stable", + "android", + "apk", + $"/api/app-updates/android/download/{Uri.EscapeDataString(apk.Name)}", + apk.Length, + await Sha256Async(apk.FullName, cancellationToken), + "QMAX local release", + apk.LastWriteTimeUtc); + + return manifest; + } + + [AllowAnonymous] + [HttpGet("android/download/{fileName}")] + public IActionResult Download(string fileName) + { + var safeName = Path.GetFileName(fileName); + var path = Path.Combine(_options.ReleasesPath, "android", safeName); + if (!System.IO.File.Exists(path) || !safeName.EndsWith(".apk", StringComparison.OrdinalIgnoreCase)) + { + return NotFound(); + } + + return PhysicalFile(path, "application/vnd.android.package-archive", safeName, enableRangeProcessing: true); + } + + private static async Task Sha256Async(string path, CancellationToken cancellationToken) + { + await using var stream = System.IO.File.OpenRead(path); + var hash = await SHA256.HashDataAsync(stream, cancellationToken); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/server/QMax.Api/Controllers/AuthController.cs b/server/QMax.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..30631db --- /dev/null +++ b/server/QMax.Api/Controllers/AuthController.cs @@ -0,0 +1,130 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +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.Auth; + +namespace QMax.Api.Controllers; + +[ApiController] +[Route("api/auth")] +public sealed class AuthController( + QMaxDbContext db, + ITokenService tokenService, + IOptions options) : ControllerBase +{ + private readonly QMaxOptions _options = options.Value; + + [AllowAnonymous] + [HttpPost("device/login")] + public async Task> Login(DeviceLoginRequest request, CancellationToken cancellationToken) + { + var expected = _options.PairingCode; + if (string.IsNullOrWhiteSpace(expected)) + { + return Problem("QMax:PairingCode is not configured on the server.", statusCode: StatusCodes.Status503ServiceUnavailable); + } + + if (!FixedTimeEquals(expected, request.PairingCode)) + { + return Unauthorized(); + } + + var user = await db.Users.FirstOrDefaultAsync(cancellationToken); + if (user is null) + { + user = new User + { + DisplayName = "QMAX Owner", + PhoneNumber = string.IsNullOrWhiteSpace(_options.MaxPhoneNumber) ? null : _options.MaxPhoneNumber + }; + db.Users.Add(user); + } + + var refreshToken = tokenService.CreateRefreshToken(); + var session = new UserSession + { + User = user, + DeviceName = string.IsNullOrWhiteSpace(request.DeviceName) ? "Android" : request.DeviceName.Trim(), + RefreshTokenHash = tokenService.HashRefreshToken(refreshToken) + }; + db.UserSessions.Add(session); + await db.SaveChangesAsync(cancellationToken); + + return CreateAuthResponse(user, session, refreshToken); + } + + [AllowAnonymous] + [HttpPost("refresh")] + public async Task> Refresh(RefreshTokenRequest request, CancellationToken cancellationToken) + { + var hash = tokenService.HashRefreshToken(request.RefreshToken); + var session = await db.UserSessions + .Include(x => x.User) + .FirstOrDefaultAsync(x => x.RefreshTokenHash == hash, cancellationToken); + + if (session?.User is null || session.RevokedAt is not null || session.ExpiresAt <= DateTimeOffset.UtcNow) + { + return Unauthorized(); + } + + session.LastSeenAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(cancellationToken); + + return CreateAuthResponse(session.User, session, request.RefreshToken); + } + + [Authorize] + [HttpPost("logout")] + public async Task Logout(CancellationToken cancellationToken) + { + var sessionId = User.GetSessionId(); + if (sessionId is not null) + { + var session = await db.UserSessions.FindAsync([sessionId.Value], cancellationToken); + if (session is not null) + { + session.RevokedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(cancellationToken); + } + } + + return NoContent(); + } + + [Authorize] + [HttpGet("sessions")] + public async Task>> Sessions(CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + var currentSessionId = User.GetSessionId(); + var sessions = (await db.UserSessions + .Where(x => x.UserId == userId && x.RevokedAt == null) + .ToArrayAsync(cancellationToken)) + .OrderByDescending(x => x.LastSeenAt) + .Select(x => new SessionDto(x.Id, x.DeviceName, x.CreatedAt, x.LastSeenAt, x.ExpiresAt, x.Id == currentSessionId)) + .ToArray(); + + return sessions; + } + + private AuthResponse CreateAuthResponse(User user, UserSession session, string refreshToken) + { + var expiresAt = DateTimeOffset.UtcNow.AddHours(8); + var accessToken = tokenService.CreateAccessToken(user, session, expiresAt); + var dto = new UserDto(user.Id, user.DisplayName, user.PhoneNumber, user.AvatarPath); + return new AuthResponse(accessToken, refreshToken, expiresAt, dto); + } + + private static bool FixedTimeEquals(string expected, string actual) + { + var expectedBytes = System.Text.Encoding.UTF8.GetBytes(expected); + var actualBytes = System.Text.Encoding.UTF8.GetBytes(actual); + return expectedBytes.Length == actualBytes.Length && + System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes); + } +} diff --git a/server/QMax.Api/Controllers/ChatsController.cs b/server/QMax.Api/Controllers/ChatsController.cs new file mode 100644 index 0000000..3ecd55b --- /dev/null +++ b/server/QMax.Api/Controllers/ChatsController.cs @@ -0,0 +1,823 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +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.Infrastructure.Storage; +using QMax.Api.Services; + +namespace QMax.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/chats")] +public sealed class ChatsController( + QMaxDbContext db, + ChatProjectionService projection, + IMaxBridgeClient maxBridge, + IAttachmentStorageService storage, + IHubContext hubContext, + MaxBridgeSyncService syncService) : ControllerBase +{ + [HttpGet] + public async Task>> GetChats(CancellationToken cancellationToken) + { + var chats = (await db.Chats.ToArrayAsync(cancellationToken)) + .OrderByDescending(x => x.IsPinned) + .ThenByDescending(x => x.UnreadCount > 0) + .ThenByDescending(x => x.LastMessageAt ?? x.UpdatedAt) + .Take(200) + .ToArray(); + + return chats.Select(projection.ToDto).ToArray(); + } + + [HttpPost("direct")] + public async Task> CreateDirect(CreateDirectChatRequest request, CancellationToken cancellationToken) + { + var chat = await db.Chats.FirstOrDefaultAsync(x => x.ExternalId == request.ExternalChatId, cancellationToken); + if (chat is null) + { + chat = new Chat + { + ExternalId = request.ExternalChatId, + Title = request.Title, + AvatarUrl = request.AvatarUrl, + Kind = ChatKind.MaxDialog + }; + db.Chats.Add(chat); + await db.SaveChangesAsync(cancellationToken); + } + + await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); + return projection.ToDto(chat); + } + + [HttpGet("{chatId:guid}/messages")] + public async Task>> GetMessages(Guid chatId, int take = 80, DateTimeOffset? before = null, CancellationToken cancellationToken = default) + { + var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + if (before is null && !string.IsNullOrWhiteSpace(chat.ExternalId)) + { + await syncService.SyncChatHistoryAsync(chat.ExternalId, cancellationToken); + db.ChangeTracker.Clear(); + chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); + if (chat is null) + { + return NotFound(); + } + } + + if (before is null && chat.UnreadCount > 0) + { + chat.UnreadCount = 0; + await db.SaveChangesAsync(cancellationToken); + await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); + } + + var query = db.Messages + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .Where(x => x.ChatId == chatId && x.DeletedAt == null); + + if (before is not null) + { + query = query.Where(x => x.SentAt < before); + } + + var messages = (await query.ToArrayAsync(cancellationToken)) + .OrderByDescending(x => x.SentAt) + .Take(Math.Clamp(take, 1, 200)) + .ToArray(); + + return messages.OrderBy(x => x.SentAt).Select(projection.ToDto).ToArray(); + } + + [HttpPost("{chatId:guid}/read")] + public async Task MarkRead(Guid chatId, CancellationToken cancellationToken) + { + var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + if (chat.UnreadCount > 0) + { + chat.UnreadCount = 0; + chat.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(cancellationToken); + await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); + } + + return NoContent(); + } + + [HttpGet("{chatId:guid}/search")] + public async Task>> SearchMessages( + Guid chatId, + [FromQuery(Name = "q")] string? query, + int take = 50, + CancellationToken cancellationToken = default) + { + var term = query?.Trim(); + if (string.IsNullOrWhiteSpace(term)) + { + return BadRequest("Search query is required."); + } + + var chatExists = await db.Chats.AnyAsync(x => x.Id == chatId, cancellationToken); + if (!chatExists) + { + return NotFound(); + } + + var messages = await db.Messages + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .Where(x => x.ChatId == chatId && x.DeletedAt == null) + .ToArrayAsync(cancellationToken); + + var results = messages + .Where(x => MatchesSearch(x, term)) + .OrderByDescending(x => x.SentAt) + .Take(Math.Clamp(take, 1, 200)) + .OrderBy(x => x.SentAt) + .Select(projection.ToDto) + .ToArray(); + + return results; + } + + [HttpGet("{chatId:guid}/avatar")] + public async Task DownloadAvatar(Guid chatId, CancellationToken cancellationToken) + { + var chat = await db.Chats + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); + + if (chat is null || string.IsNullOrWhiteSpace(chat.AvatarUrl)) + { + return NotFound(); + } + + var remote = await maxBridge.DownloadMediaAsync(chat.AvatarUrl, cancellationToken); + if (remote is null) + { + return NotFound(); + } + + return File(remote.Stream, remote.ContentType); + } + + [HttpGet("{chatId:guid}/presence")] + public async Task> GetPresence(Guid chatId, CancellationToken cancellationToken) + { + var chat = await db.Chats + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + if (string.IsNullOrWhiteSpace(chat.ExternalId)) + { + return new ChatPresenceDto(false, null, DateTimeOffset.UtcNow); + } + + var presence = await maxBridge.FetchChatPresenceAsync(chat.ExternalId, cancellationToken); + return new ChatPresenceDto( + presence?.IsTyping == true, + presence?.StatusText, + presence?.UpdatedAt ?? DateTimeOffset.UtcNow); + } + + [HttpPost("{chatId:guid}/messages")] + public async Task> SendMessage(Guid chatId, SendMessageRequest request, CancellationToken cancellationToken) + { + var chat = await db.Chats.FindAsync([chatId], cancellationToken); + if (chat is null) + { + return NotFound(); + } + + var text = (request.Text ?? "").Trim(); + if (string.IsNullOrWhiteSpace(text)) + { + return BadRequest("Message text is required."); + } + + if (string.IsNullOrWhiteSpace(chat.ExternalId)) + { + return BadRequest("Chat is not linked to MAX."); + } + + var result = await maxBridge.SendTextAsync(chat.ExternalId, text, cancellationToken); + if (!result.Success) + { + return MaxSendFailure(result, "text message"); + } + + var message = new Message + { + ChatId = chat.Id, + ExternalId = result.ExternalMessageId, + Direction = MessageDirection.Outgoing, + Text = text, + ReplyToMessageId = request.ReplyToMessageId, + SentAt = DateTimeOffset.UtcNow, + DeliveryState = MessageDeliveryState.Sent, + SenderName = "You" + }; + db.Messages.Add(message); + chat.LastMessagePreview = text; + chat.LastMessageAt = message.SentAt; + chat.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(cancellationToken); + + var dto = projection.ToDto(message); + await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken); + await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); + return dto; + } + + [HttpPatch("{chatId:guid}/messages/{messageId:guid}")] + public async Task> EditMessage( + Guid chatId, + Guid messageId, + EditMessageRequest request, + CancellationToken cancellationToken) + { + var text = request.Text.Trim(); + if (string.IsNullOrWhiteSpace(text)) + { + return BadRequest("Message text is required."); + } + + var message = await db.Messages + .Include(x => x.Chat) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken); + if (message is null) + { + return NotFound(); + } + + if (message.Direction != MessageDirection.Outgoing) + { + return Forbid(); + } + + var maxFailure = await TryApplyMaxActionAsync( + message, + (externalChatId, externalMessageId) => maxBridge.EditMessageAsync(externalChatId, externalMessageId, message.Text, text, cancellationToken), + "edit", + cancellationToken); + if (maxFailure is not null) + { + return maxFailure; + } + + message.Text = text; + message.EditedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(cancellationToken); + + if (message.Chat is not null) + { + await RecalculateChatPreviewAsync(message.Chat, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + } + + var dto = projection.ToDto(message); + await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken); + await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); + return dto; + } + + [HttpDelete("{chatId:guid}/messages/{messageId:guid}")] + public async Task DeleteMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken) + { + var message = await db.Messages + .Include(x => x.Chat) + .FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken); + if (message is null) + { + return NotFound(); + } + + var maxFailure = await TryApplyMaxActionAsync( + message, + (externalChatId, externalMessageId) => maxBridge.DeleteMessageAsync(externalChatId, externalMessageId, message.Text, cancellationToken), + "delete", + cancellationToken); + if (maxFailure is not null) + { + return maxFailure; + } + + var deletedAt = DateTimeOffset.UtcNow; + message.DeletedAt = deletedAt; + message.EditedAt = message.EditedAt ?? deletedAt; + await db.SaveChangesAsync(cancellationToken); + + if (message.Chat is not null) + { + await RecalculateChatPreviewAsync(message.Chat, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + } + + await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageDeleted", new MessageDeletedDto(chatId, messageId), cancellationToken); + await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); + return NoContent(); + } + + [HttpPut("{chatId:guid}/messages/{messageId:guid}/reaction")] + public async Task> SetReaction( + Guid chatId, + Guid messageId, + SetReactionRequest request, + CancellationToken cancellationToken) + { + var emoji = NormalizeReaction(request.Emoji); + if (emoji is null) + { + return BadRequest("Reaction is required."); + } + + var message = await db.Messages + .Include(x => x.Chat) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken); + if (message is null) + { + return NotFound(); + } + + var maxFailure = await TryApplyMaxActionAsync( + message, + (externalChatId, externalMessageId) => maxBridge.SetReactionAsync(externalChatId, externalMessageId, message.Text, emoji, cancellationToken), + "reaction", + cancellationToken); + if (maxFailure is not null) + { + return maxFailure; + } + + var reaction = message.Reactions.FirstOrDefault(x => x.ActorKey == "self"); + if (reaction is null) + { + reaction = new MessageReaction + { + MessageId = message.Id, + Emoji = emoji, + ActorKey = "self", + ActorName = "You" + }; + db.MessageReactions.Add(reaction); + } + else + { + reaction.Emoji = emoji; + reaction.ActorName = "You"; + reaction.CreatedAt = DateTimeOffset.UtcNow; + } + + await db.SaveChangesAsync(cancellationToken); + var updated = await LoadMessageForProjectionAsync(chatId, messageId, cancellationToken) ?? message; + var dto = projection.ToDto(updated); + await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken); + return dto; + } + + [HttpDelete("{chatId:guid}/messages/{messageId:guid}/reaction")] + public async Task> ClearReaction(Guid chatId, Guid messageId, CancellationToken cancellationToken) + { + var message = await db.Messages + .Include(x => x.Chat) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken); + if (message is null) + { + return NotFound(); + } + + var reaction = message.Reactions.FirstOrDefault(x => x.ActorKey == "self"); + if (reaction is not null) + { + var maxFailure = await TryApplyMaxActionAsync( + message, + (externalChatId, externalMessageId) => maxBridge.ClearReactionAsync(externalChatId, externalMessageId, message.Text, reaction.Emoji, cancellationToken), + "reaction clear", + cancellationToken); + if (maxFailure is not null) + { + return maxFailure; + } + + message.Reactions.Remove(reaction); + db.MessageReactions.Remove(reaction); + await db.SaveChangesAsync(cancellationToken); + } + + var updated = await LoadMessageForProjectionAsync(chatId, messageId, cancellationToken) ?? message; + var dto = projection.ToDto(updated); + await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken); + return dto; + } + + [HttpPost("{chatId:guid}/messages/{messageId:guid}/forward")] + public async Task> ForwardMessage( + Guid chatId, + Guid messageId, + ForwardMessageRequest request, + CancellationToken cancellationToken) + { + var source = await db.Messages + .Include(x => x.Chat) + .Include(x => x.Attachments) + .FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken); + if (source is null) + { + return NotFound(); + } + + var target = await db.Chats.FindAsync([request.TargetChatId], cancellationToken); + if (target is null) + { + return NotFound(); + } + + if (string.IsNullOrWhiteSpace(target.ExternalId)) + { + return BadRequest("Target chat is not linked to MAX."); + } + + if (string.IsNullOrWhiteSpace(source.Text) && source.Attachments.Count == 0) + { + return BadRequest("Cannot forward an empty message."); + } + + var message = new Message + { + ChatId = target.Id, + Direction = MessageDirection.Outgoing, + Text = source.Text, + ForwardedFrom = ResolveForwardedFrom(source), + SentAt = DateTimeOffset.UtcNow, + DeliveryState = MessageDeliveryState.Sending, + SenderName = "You" + }; + + var sortOrder = 0; + foreach (var attachment in source.Attachments.OrderBy(x => x.SortOrder)) + { + StoredAttachment stored; + try + { + stored = await CopyForwardAttachmentAsync(attachment, cancellationToken); + } + catch (InvalidOperationException error) + { + return Problem(error.Message, statusCode: StatusCodes.Status409Conflict); + } + + message.Attachments.Add(new MessageAttachment + { + OriginalFileName = stored.OriginalFileName, + StorageFileName = stored.StorageFileName, + ContentType = stored.ContentType, + FileSizeBytes = stored.FileSizeBytes, + Sha256 = stored.Sha256, + Kind = stored.Kind, + SortOrder = sortOrder++ + }); + } + + var result = await SendForwardedToMaxAsync(target.ExternalId, message, cancellationToken); + if (!result.Success) + { + return MaxSendFailure(result, "forward"); + } + + message.ExternalId = result.ExternalMessageId; + message.DeliveryState = MessageDeliveryState.Sent; + db.Messages.Add(message); + target.LastMessagePreview = ForwardPreview(message); + target.LastMessageAt = message.SentAt; + target.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(cancellationToken); + + var dto = projection.ToDto(message); + await hubContext.Clients.Group($"chat:{target.Id}").SendAsync("MessageCreated", dto, cancellationToken); + await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); + return dto; + } + + [HttpPost("{chatId:guid}/attachments")] + [RequestSizeLimit(30L * 1024 * 1024)] + public async Task> UploadAttachment( + Guid chatId, + [FromForm] IFormFile file, + [FromForm] string? caption, + [FromForm] Guid? replyToMessageId, + CancellationToken cancellationToken) + { + var chat = await db.Chats.FindAsync([chatId], cancellationToken); + if (chat is null) + { + return NotFound(); + } + + if (string.IsNullOrWhiteSpace(chat.ExternalId)) + { + return BadRequest("Chat is not linked to MAX."); + } + + var stored = await storage.SaveAsync(file, cancellationToken); + var result = await maxBridge.SendAttachmentAsync(chat.ExternalId, storage.GetPath(stored.StorageFileName), caption ?? "", cancellationToken); + if (!result.Success) + { + return MaxSendFailure(result, "attachment"); + } + + var message = new Message + { + ChatId = chat.Id, + ExternalId = result.ExternalMessageId, + Direction = MessageDirection.Outgoing, + Text = caption, + ReplyToMessageId = replyToMessageId, + SentAt = DateTimeOffset.UtcNow, + DeliveryState = MessageDeliveryState.Sent, + SenderName = "You", + Attachments = + [ + new MessageAttachment + { + OriginalFileName = stored.OriginalFileName, + StorageFileName = stored.StorageFileName, + ContentType = stored.ContentType, + FileSizeBytes = stored.FileSizeBytes, + Sha256 = stored.Sha256, + Kind = stored.Kind + } + ] + }; + + db.Messages.Add(message); + chat.LastMessagePreview = !string.IsNullOrWhiteSpace(caption) + ? caption + : stored.Kind switch + { + AttachmentKind.Image => "\u041c\u0435\u0434\u0438\u0430", + AttachmentKind.Gif => "\u041c\u0435\u0434\u0438\u0430", + AttachmentKind.Video => "\u041c\u0435\u0434\u0438\u0430", + AttachmentKind.VoiceNote => "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435", + AttachmentKind.Sticker => "\u041c\u0435\u0434\u0438\u0430", + AttachmentKind.Contact => "\u041a\u043e\u043d\u0442\u0430\u043a\u0442", + _ => stored.OriginalFileName + }; + chat.LastMessageAt = message.SentAt; + chat.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(cancellationToken); + + var dto = projection.ToDto(message); + await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken); + await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); + return dto; + } + + [HttpGet("{chatId:guid}/attachments/{attachmentId:guid}/content")] + public async Task DownloadAttachment(Guid chatId, Guid attachmentId, CancellationToken cancellationToken) + { + var attachment = await db.MessageAttachments + .Include(x => x.Message) + .FirstOrDefaultAsync(x => x.Id == attachmentId && x.Message!.ChatId == chatId, cancellationToken); + + if (attachment is null) + { + return NotFound(); + } + + var path = storage.GetPath(attachment.StorageFileName); + if (!System.IO.File.Exists(path)) + { + if (string.IsNullOrWhiteSpace(attachment.RemoteUrl)) + { + return NotFound(); + } + + var remote = await maxBridge.DownloadMediaAsync(attachment.RemoteUrl, cancellationToken); + if (remote is null) + { + return NotFound(); + } + + await using (remote.Stream) + { + var stored = await storage.SaveRemoteAsync( + attachment.OriginalFileName, + remote.ContentType, + remote.Stream, + remote.ContentLength ?? attachment.FileSizeBytes, + attachment.Kind, + cancellationToken); + + attachment.OriginalFileName = stored.OriginalFileName; + attachment.StorageFileName = stored.StorageFileName; + attachment.ContentType = stored.ContentType; + attachment.FileSizeBytes = stored.FileSizeBytes; + attachment.Sha256 = stored.Sha256; + attachment.Kind = stored.Kind; + await db.SaveChangesAsync(cancellationToken); + + path = storage.GetPath(attachment.StorageFileName); + } + } + + return PhysicalFile(path, attachment.ContentType, attachment.OriginalFileName, enableRangeProcessing: true); + } + + private ObjectResult MaxSendFailure(MaxSendResult result, string itemName) + { + return Problem( + result.Error ?? $"MAX did not accept the {itemName}.", + statusCode: StatusCodes.Status502BadGateway); + } + + private async Task TryApplyMaxActionAsync( + Message message, + Func> action, + string actionName, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var externalChatId = message.Chat?.ExternalId; + var externalMessageId = message.ExternalId; + if (string.IsNullOrWhiteSpace(externalChatId) || string.IsNullOrWhiteSpace(externalMessageId)) + { + return null; + } + + var result = await action(externalChatId, externalMessageId); + if (result.Success) + { + return null; + } + + return Problem( + result.Error ?? $"MAX {actionName} action failed.", + statusCode: StatusCodes.Status409Conflict); + } + + private static string? NormalizeReaction(string? value) + { + var emoji = value?.Trim(); + return string.IsNullOrWhiteSpace(emoji) || emoji.Length > 16 ? null : emoji; + } + + private async Task LoadMessageForProjectionAsync(Guid chatId, Guid messageId, CancellationToken cancellationToken) + { + return await db.Messages + .AsNoTracking() + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken); + } + + private async Task RecalculateChatPreviewAsync(Chat chat, CancellationToken cancellationToken) + { + var lastMessage = (await db.Messages + .Include(x => x.Attachments) + .Where(x => x.ChatId == chat.Id && x.DeletedAt == null) + .ToArrayAsync(cancellationToken)) + .OrderByDescending(x => x.SentAt) + .FirstOrDefault(); + + chat.LastMessageAt = lastMessage?.SentAt; + chat.LastMessagePreview = lastMessage is null ? null : ForwardPreview(lastMessage); + chat.UpdatedAt = DateTimeOffset.UtcNow; + } + + private async Task CopyForwardAttachmentAsync(MessageAttachment attachment, CancellationToken cancellationToken) + { + var path = storage.GetPath(attachment.StorageFileName); + if (System.IO.File.Exists(path)) + { + await using var stream = System.IO.File.OpenRead(path); + return await storage.SaveRemoteAsync( + attachment.OriginalFileName, + attachment.ContentType, + stream, + attachment.FileSizeBytes, + attachment.Kind, + cancellationToken); + } + + if (!string.IsNullOrWhiteSpace(attachment.RemoteUrl)) + { + var remote = await maxBridge.DownloadMediaAsync(attachment.RemoteUrl, cancellationToken); + if (remote is not null) + { + await using (remote.Stream) + { + return await storage.SaveRemoteAsync( + attachment.OriginalFileName, + remote.ContentType, + remote.Stream, + remote.ContentLength ?? attachment.FileSizeBytes, + attachment.Kind, + cancellationToken); + } + } + } + + throw new InvalidOperationException($"Cannot forward attachment {attachment.Id}: content is unavailable."); + } + + private async Task SendForwardedToMaxAsync(string externalChatId, Message message, CancellationToken cancellationToken) + { + var attachments = message.Attachments.OrderBy(x => x.SortOrder).ToArray(); + if (attachments.Length == 0) + { + return string.IsNullOrWhiteSpace(message.Text) + ? new MaxSendResult(false, null, "Forwarded message is empty.") + : await maxBridge.SendTextAsync(externalChatId, message.Text, cancellationToken); + } + + MaxSendResult? lastResult = null; + var errors = new List(); + foreach (var attachment in attachments) + { + var caption = attachment.SortOrder == 0 ? message.Text ?? "" : ""; + var result = await maxBridge.SendAttachmentAsync(externalChatId, storage.GetPath(attachment.StorageFileName), caption, cancellationToken); + lastResult = result; + if (!result.Success && !string.IsNullOrWhiteSpace(result.Error)) + { + errors.Add(result.Error); + } + } + + return errors.Count == 0 + ? new MaxSendResult(true, lastResult?.ExternalMessageId, null) + : new MaxSendResult(false, lastResult?.ExternalMessageId, string.Join("; ", errors.Distinct())); + } + + private static string? ResolveForwardedFrom(Message source) + { + return source.SenderName?.Trim().Length > 0 + ? source.SenderName.Trim() + : source.Chat?.Title; + } + + private static string ForwardPreview(Message message) + { + if (!string.IsNullOrWhiteSpace(message.Text)) + { + return message.Text; + } + + var first = message.Attachments.OrderBy(x => x.SortOrder).FirstOrDefault(); + return first?.Kind switch + { + AttachmentKind.Image => "\u041c\u0435\u0434\u0438\u0430", + AttachmentKind.Gif => "\u041c\u0435\u0434\u0438\u0430", + AttachmentKind.Video => "\u041c\u0435\u0434\u0438\u0430", + AttachmentKind.VoiceNote => "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435", + AttachmentKind.Sticker => "\u041c\u0435\u0434\u0438\u0430", + AttachmentKind.Contact => "\u041a\u043e\u043d\u0442\u0430\u043a\u0442", + _ => first?.OriginalFileName ?? "\u041f\u0435\u0440\u0435\u0441\u043b\u0430\u043d\u043d\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435" + }; + } + + private static bool MatchesSearch(Message message, string term) + { + return Contains(message.Text, term) || + Contains(message.SenderName, term) || + Contains(message.ForwardedFrom, term) || + message.Attachments.Any(x => + Contains(x.OriginalFileName, term) || + Contains(x.ContentType, term)) || + message.Reactions.Any(x => Contains(x.Emoji, term)); + } + + private static bool Contains(string? value, string term) + { + return value?.Contains(term, StringComparison.OrdinalIgnoreCase) == true; + } +} diff --git a/server/QMax.Api/Controllers/MaxController.cs b/server/QMax.Api/Controllers/MaxController.cs new file mode 100644 index 0000000..cf959e2 --- /dev/null +++ b/server/QMax.Api/Controllers/MaxController.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +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.Max; +using QMax.Api.Services; + +namespace QMax.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/max")] +public sealed class MaxController( + IMaxBridgeClient maxBridge, + MaxBridgeSyncService syncService, + QMaxDbContext db, + IOptions options) : ControllerBase +{ + private readonly QMaxOptions _options = options.Value; + + [HttpGet("status")] + public async Task> Status(CancellationToken cancellationToken) + { + var status = await maxBridge.GetStatusAsync(cancellationToken); + await SaveStateAsync(status, cancellationToken); + return ToDto(status); + } + + [HttpPost("login/start")] + public async Task> BeginLogin(BeginMaxLoginRequest request, CancellationToken cancellationToken) + { + var phone = string.IsNullOrWhiteSpace(request.PhoneNumber) ? _options.MaxPhoneNumber : request.PhoneNumber; + if (string.IsNullOrWhiteSpace(phone)) + { + return BadRequest("Phone number is required."); + } + + var status = await maxBridge.BeginPhoneLoginAsync(phone, cancellationToken); + await SaveStateAsync(status, cancellationToken); + return ToDto(status); + } + + [HttpPost("login/code")] + public async Task> SubmitCode(SubmitMaxCodeRequest request, CancellationToken cancellationToken) + { + var status = await maxBridge.SubmitLoginCodeAsync(request.Code, cancellationToken); + await SaveStateAsync(status, cancellationToken); + return ToDto(status); + } + + [HttpGet("browser/snapshot")] + public async Task> Snapshot(CancellationToken cancellationToken) + { + var snapshot = await maxBridge.GetSnapshotAsync(cancellationToken); + return new MaxBrowserSnapshotDto(snapshot.Url, snapshot.Title, snapshot.BodyText, snapshot.ScreenshotPngBase64, snapshot.CapturedAt); + } + + [HttpPost("sync")] + public async Task> Sync(CancellationToken cancellationToken) + { + var changed = await syncService.SyncOnceAsync(cancellationToken); + return new { changed }; + } + + private async Task SaveStateAsync(MaxBridgeStatus status, CancellationToken cancellationToken) + { + 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); + } + + 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); + } +} diff --git a/server/QMax.Api/Controllers/PushController.cs b/server/QMax.Api/Controllers/PushController.cs new file mode 100644 index 0000000..0bf561c --- /dev/null +++ b/server/QMax.Api/Controllers/PushController.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using QMax.Api.Data; +using QMax.Api.Data.Entities; +using QMax.Api.Infrastructure.Auth; + +namespace QMax.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/push")] +public sealed class PushController(QMaxDbContext db) : ControllerBase +{ + public sealed record RegisterPushDeviceRequest(string FirebaseToken, string Platform); + + [HttpPost("devices")] + public async Task Register(RegisterPushDeviceRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.FirebaseToken)) + { + return BadRequest("firebaseToken is required."); + } + + var userId = User.GetUserId(); + var device = await db.PushDevices.FirstOrDefaultAsync(x => x.FirebaseToken == request.FirebaseToken, cancellationToken); + if (device is null) + { + device = new PushDevice { FirebaseToken = request.FirebaseToken }; + db.PushDevices.Add(device); + } + + device.UserId = userId; + device.Platform = string.IsNullOrWhiteSpace(request.Platform) ? "android" : request.Platform; + device.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpDelete("devices/{id:guid}")] + public async Task Delete(Guid id, CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + var device = await db.PushDevices.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId, cancellationToken); + if (device is null) + { + return NotFound(); + } + + db.PushDevices.Remove(device); + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } +} diff --git a/server/QMax.Api/Controllers/StatusController.cs b/server/QMax.Api/Controllers/StatusController.cs new file mode 100644 index 0000000..c77d069 --- /dev/null +++ b/server/QMax.Api/Controllers/StatusController.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using QMax.Api.Data; + +namespace QMax.Api.Controllers; + +[ApiController] +[Route("api/status")] +public sealed class StatusController(QMaxDbContext db) : ControllerBase +{ + [AllowAnonymous] + [HttpGet] + public async Task> Get(CancellationToken cancellationToken) + { + return new + { + name = "QMAX Bridge", + status = "ok", + serverTime = DateTimeOffset.UtcNow, + chats = await db.Chats.CountAsync(cancellationToken), + messages = await db.Messages.CountAsync(cancellationToken) + }; + } +} diff --git a/server/QMax.Api/Infrastructure/Auth/TokenService.cs b/server/QMax.Api/Infrastructure/Auth/TokenService.cs new file mode 100644 index 0000000..098dc90 --- /dev/null +++ b/server/QMax.Api/Infrastructure/Auth/TokenService.cs @@ -0,0 +1,74 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using QMax.Api.Configuration; +using QMax.Api.Data.Entities; + +namespace QMax.Api.Infrastructure.Auth; + +public interface ITokenService +{ + string CreateAccessToken(User user, UserSession session, DateTimeOffset expiresAt); + string CreateRefreshToken(); + string HashRefreshToken(string refreshToken); +} + +public sealed class TokenService(IOptions options) : ITokenService +{ + private readonly QMaxOptions _options = options.Value; + + public string CreateAccessToken(User user, UserSession session, DateTimeOffset expiresAt) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetJwtSecret())); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim("sid", session.Id.ToString()), + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.DisplayName) + }; + + var token = new JwtSecurityToken( + issuer: _options.JwtIssuer, + audience: _options.JwtAudience, + claims: claims, + notBefore: DateTime.UtcNow, + expires: expiresAt.UtcDateTime, + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public string CreateRefreshToken() + { + Span bytes = stackalloc byte[48]; + RandomNumberGenerator.Fill(bytes); + return Convert.ToBase64String(bytes); + } + + public string HashRefreshToken(string refreshToken) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken)); + return Convert.ToHexString(bytes); + } + + private string GetJwtSecret() + { + if (!string.IsNullOrWhiteSpace(_options.JwtSecret) && _options.JwtSecret.Length >= 32) + { + return _options.JwtSecret; + } + + if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development") + { + return "development-only-qmax-secret-change-before-production"; + } + + throw new InvalidOperationException("QMax:JwtSecret must be set to at least 32 characters in production."); + } +} diff --git a/server/QMax.Api/Infrastructure/Auth/UserContext.cs b/server/QMax.Api/Infrastructure/Auth/UserContext.cs new file mode 100644 index 0000000..6a2acb3 --- /dev/null +++ b/server/QMax.Api/Infrastructure/Auth/UserContext.cs @@ -0,0 +1,20 @@ +using System.Security.Claims; + +namespace QMax.Api.Infrastructure.Auth; + +public static class UserContext +{ + public static Guid GetUserId(this ClaimsPrincipal principal) + { + var value = principal.FindFirstValue(ClaimTypes.NameIdentifier) + ?? principal.FindFirstValue("sub") + ?? throw new InvalidOperationException("Authenticated user id is missing."); + return Guid.Parse(value); + } + + public static Guid? GetSessionId(this ClaimsPrincipal principal) + { + var value = principal.FindFirstValue("sid"); + return Guid.TryParse(value, out var id) ? id : null; + } +} diff --git a/server/QMax.Api/Infrastructure/Hubs/QMaxHub.cs b/server/QMax.Api/Infrastructure/Hubs/QMaxHub.cs new file mode 100644 index 0000000..30bd794 --- /dev/null +++ b/server/QMax.Api/Infrastructure/Hubs/QMaxHub.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace QMax.Api.Infrastructure.Hubs; + +[Authorize] +public sealed class QMaxHub : Hub +{ + public Task JoinChat(string chatId) + { + return Groups.AddToGroupAsync(Context.ConnectionId, $"chat:{chatId}"); + } + + public Task LeaveChat(string chatId) + { + return Groups.RemoveFromGroupAsync(Context.ConnectionId, $"chat:{chatId}"); + } +} diff --git a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs new file mode 100644 index 0000000..065d741 --- /dev/null +++ b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs @@ -0,0 +1,19 @@ +namespace QMax.Api.Infrastructure.Max; + +public interface IMaxBridgeClient +{ + Task GetStatusAsync(CancellationToken cancellationToken); + Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken); + Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken); + Task GetSnapshotAsync(CancellationToken cancellationToken); + Task> FetchUpdatesAsync(CancellationToken cancellationToken); + Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken); + Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken); + Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken); + Task SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken); + Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken); + Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken); + Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken); + Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken); + Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken); +} diff --git a/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs b/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs new file mode 100644 index 0000000..8361584 --- /dev/null +++ b/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs @@ -0,0 +1,63 @@ +namespace QMax.Api.Infrastructure.Max; + +public sealed record MaxBridgeStatus( + string Mode, + bool IsAuthorized, + string? LoginStage, + string Status, + string? Url, + string? Title, + string? LastError, + DateTimeOffset UpdatedAt); + +public sealed record MaxBrowserSnapshot( + string? Url, + string? Title, + string BodyText, + string ScreenshotPngBase64, + DateTimeOffset CapturedAt); + +public sealed record MaxChatUpdate( + string ExternalId, + string Title, + string? AvatarUrl, + DateTimeOffset UpdatedAt, + IReadOnlyList Messages, + string? LastMessagePreview = null, + bool? LastMessageIsOutgoing = null, + DateTimeOffset? LastMessageAt = null, + string? LastMessageTimeText = null); + +public sealed record MaxMessageUpdate( + string ExternalId, + string? SenderExternalId, + string? SenderName, + bool IsOutgoing, + string? Text, + DateTimeOffset SentAt, + IReadOnlyList? Attachments = null, + string? DeliveryState = null); + +public sealed record MaxAttachmentUpdate( + string ExternalId, + string FileName, + string? ContentType, + long? FileSizeBytes, + string? RemoteUrl, + string? Kind, + int SortOrder, + string? TextContent = null); + +public sealed record MaxSendResult(bool Success, string? ExternalMessageId, string? Error); + +public sealed record MaxActionResult(bool Success, string? Error); + +public sealed record MaxMediaDownload( + Stream Stream, + string ContentType, + long? ContentLength); + +public sealed record MaxChatPresence( + bool IsTyping, + string? StatusText, + DateTimeOffset UpdatedAt); diff --git a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs new file mode 100644 index 0000000..5e8dfae --- /dev/null +++ b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs @@ -0,0 +1,93 @@ +namespace QMax.Api.Infrastructure.Max; + +public sealed class MockMaxBridgeClient : IMaxBridgeClient +{ + private readonly List _updates = + [ + new MaxChatUpdate( + "mock-qmax", + "QMAX smoke chat", + null, + DateTimeOffset.UtcNow, + [ + new MaxMessageUpdate( + "mock-message-1", + "max", + "MAX", + false, + "Mock mode is active. Switch QMax:MaxMode to Playwright for real web.max.ru.", + DateTimeOffset.UtcNow) + ]) + ]; + + public Task GetStatusAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MaxBridgeStatus("Mock", true, "Authorized", "MockReady", null, "Mock", null, DateTimeOffset.UtcNow)); + } + + public Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) + { + return GetStatusAsync(cancellationToken); + } + + public Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) + { + return GetStatusAsync(cancellationToken); + } + + public Task GetSnapshotAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MaxBrowserSnapshot(null, "Mock", "Mock MAX bridge", "", DateTimeOffset.UtcNow)); + } + + public Task> FetchUpdatesAsync(CancellationToken cancellationToken) + { + return Task.FromResult>(_updates); + } + + public Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + { + var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId); + return Task.FromResult(update); + } + + public Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxChatPresence(false, null, DateTimeOffset.UtcNow)); + } + + public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(true, $"mock-out-{Guid.NewGuid():N}", null)); + } + + public Task SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(true, $"mock-file-{Guid.NewGuid():N}", null)); + } + + public Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } +} diff --git a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs new file mode 100644 index 0000000..37c5eb3 --- /dev/null +++ b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs @@ -0,0 +1,219 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Options; +using QMax.Api.Configuration; + +namespace QMax.Api.Infrastructure.Max; + +public sealed class WorkerMaxBridgeClient( + HttpClient httpClient, + IOptions options, + ILogger logger) : IMaxBridgeClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly QMaxOptions _options = options.Value; + + public async Task GetStatusAsync(CancellationToken cancellationToken) + { + return await SendAsync(HttpMethod.Get, "/status", null, cancellationToken) + ?? ErrorStatus("Worker returned an empty status."); + } + + public async Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) + { + return await SendAsync(HttpMethod.Post, "/login/start", new { phoneNumber }, cancellationToken) + ?? ErrorStatus("Worker returned an empty login status."); + } + + public async Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) + { + return await SendAsync(HttpMethod.Post, "/login/code", new { code, waitMs = 60000 }, cancellationToken) + ?? ErrorStatus("Worker returned an empty code status."); + } + + public async Task GetSnapshotAsync(CancellationToken cancellationToken) + { + return await SendAsync(HttpMethod.Get, "/snapshot", null, cancellationToken) + ?? new MaxBrowserSnapshot(null, "Worker unavailable", "", "", DateTimeOffset.UtcNow); + } + + public async Task> FetchUpdatesAsync(CancellationToken cancellationToken) + { + return await SendAsync>(HttpMethod.Get, "/updates", null, cancellationToken) + ?? Array.Empty(); + } + + public async Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + { + return await SendAsync(HttpMethod.Post, "/chat/history", new { externalChatId }, cancellationToken); + } + + public async Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) + { + return await SendAsync(HttpMethod.Post, "/chat/presence", new { externalChatId }, cancellationToken); + } + + public async Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) + { + return await SendAsync(HttpMethod.Post, "/send/text", new { externalChatId, text }, cancellationToken) + ?? new MaxSendResult(false, null, "Worker returned an empty send result."); + } + + public async Task SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) + { + return await SendAsync(HttpMethod.Post, "/send/attachment", new { externalChatId, path = ToWorkerPath(path), caption }, cancellationToken) + ?? new MaxSendResult(false, null, "Worker returned an empty attachment result."); + } + + public async Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) + { + return await SendAsync( + HttpMethod.Post, + "/message/edit", + new { externalChatId, externalMessageId, currentText, text }, + cancellationToken) + ?? new MaxActionResult(false, "Worker returned an empty edit result."); + } + + public async Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) + { + return await SendAsync( + HttpMethod.Post, + "/message/delete", + new { externalChatId, externalMessageId, currentText }, + cancellationToken) + ?? new MaxActionResult(false, "Worker returned an empty delete result."); + } + + public async Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return await SendAsync( + HttpMethod.Post, + "/message/reaction", + new { externalChatId, externalMessageId, currentText, emoji }, + cancellationToken) + ?? new MaxActionResult(false, "Worker returned an empty reaction result."); + } + + public async Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return await SendAsync( + HttpMethod.Delete, + "/message/reaction", + new { externalChatId, externalMessageId, currentText, emoji }, + cancellationToken) + ?? new MaxActionResult(false, "Worker returned an empty reaction clear result."); + } + + public async Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(remoteUrl)) + { + return null; + } + + try + { + httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/"); + var path = $"media/fetch?url={Uri.EscapeDataString(remoteUrl)}"; + var response = await httpClient.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogWarning("MAX worker media fetch failed with {Status}: {Content}", response.StatusCode, content); + response.Dispose(); + return null; + } + + var contentType = response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream"; + var contentLength = response.Content.Headers.ContentLength; + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return new MaxMediaDownload(new ResponseDisposingStream(stream, response), contentType, contentLength); + } + catch (Exception ex) + { + logger.LogWarning(ex, "MAX worker media fetch request failed."); + return null; + } + } + + private async Task SendAsync(HttpMethod method, string path, object? body, CancellationToken cancellationToken) + { + try + { + httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/"); + using var request = new HttpRequestMessage(method, path.TrimStart('/')); + if (body is not null) + { + request.Content = JsonContent.Create(body, options: JsonOptions); + } + + using var response = await httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogWarning("MAX worker {Path} failed with {Status}: {Content}", path, response.StatusCode, content); + return default; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "MAX worker {Path} request failed.", path); + return default; + } + } + + private static MaxBridgeStatus ErrorStatus(string error) + { + return new MaxBridgeStatus("Worker", false, "Unavailable", "WorkerUnavailable", null, null, error, DateTimeOffset.UtcNow); + } + + private static string ToWorkerPath(string path) + { + var normalized = path.Replace('\\', '/'); + return normalized.StartsWith("/data/", StringComparison.Ordinal) + ? "/qmax-data/" + normalized["/data/".Length..] + : normalized; + } + + private sealed class ResponseDisposingStream(Stream inner, HttpResponseMessage response) : Stream + { + public override bool CanRead => inner.CanRead; + public override bool CanSeek => inner.CanSeek; + public override bool CanWrite => inner.CanWrite; + public override long Length => inner.Length; + public override long Position + { + get => inner.Position; + set => inner.Position = value; + } + + public override void Flush() => inner.Flush(); + public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); + public override void SetLength(long value) => inner.SetLength(value); + public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count); + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + await inner.ReadAsync(buffer, cancellationToken); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + inner.Dispose(); + response.Dispose(); + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + await inner.DisposeAsync(); + response.Dispose(); + await base.DisposeAsync(); + } + } +} diff --git a/server/QMax.Api/Program.cs b/server/QMax.Api/Program.cs new file mode 100644 index 0000000..d9a2328 --- /dev/null +++ b/server/QMax.Api/Program.cs @@ -0,0 +1,238 @@ +using System.Text; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using QMax.Api.Configuration; +using QMax.Api.Data; +using QMax.Api.Infrastructure.Auth; +using QMax.Api.Infrastructure.Hubs; +using QMax.Api.Infrastructure.Max; +using QMax.Api.Infrastructure.Storage; +using QMax.Api.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(builder.Configuration.GetSection("QMax")); +var configuredOptions = builder.Configuration.GetSection("QMax").Get() ?? new QMaxOptions(); +var databaseDirectory = Path.GetDirectoryName(Path.GetFullPath(configuredOptions.DatabasePath)); +if (!string.IsNullOrWhiteSpace(databaseDirectory)) +{ + Directory.CreateDirectory(databaseDirectory); +} + +builder.Services.AddDbContext(options => +{ + options.UseSqlite($"Data Source={configuredOptions.DatabasePath}"); +}); + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + var origins = configuredOptions.CorsAllowedOrigins + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + if (origins.Length > 0) + { + policy.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials(); + } + else + { + policy.AllowAnyHeader().AllowAnyMethod().SetIsOriginAllowed(_ => true).AllowCredentials(); + } + }); +}); + +var jwtSecret = !string.IsNullOrWhiteSpace(configuredOptions.JwtSecret) && configuredOptions.JwtSecret.Length >= 32 + ? configuredOptions.JwtSecret + : builder.Environment.IsDevelopment() + ? "development-only-qmax-secret-change-before-production" + : throw new InvalidOperationException("QMax:JwtSecret must be set to at least 32 characters in production."); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = configuredOptions.JwtIssuer, + ValidAudience = configuredOptions.JwtAudience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), + ClockSkew = TimeSpan.FromMinutes(1) + }; + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + var path = context.HttpContext.Request.Path; + if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs/qmax")) + { + context.Token = accessToken; + } + + return Task.CompletedTask; + } + }; + }); + +builder.Services.AddAuthorization(); +builder.Services.AddControllers().AddJsonOptions(options => +{ + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); +builder.Services.AddSignalR(); +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +if (string.Equals(configuredOptions.MaxMode, "Mock", StringComparison.OrdinalIgnoreCase)) +{ + builder.Services.AddSingleton(); +} +else +{ + builder.Services.AddHttpClient(); +} + +var app = builder.Build(); + +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto +}); + +app.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.MapHub("/hubs/qmax"); + +using (var scope = app.Services.CreateScope()) +{ + var options = scope.ServiceProvider.GetRequiredService>().Value; + Directory.CreateDirectory(options.StoragePath); + Directory.CreateDirectory(options.ReleasesPath); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + await EnsureCompatibilitySchemaAsync(db); +} + +app.Run(); + +static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db) +{ + var connection = db.Database.GetDbConnection(); + if (connection.State != System.Data.ConnectionState.Open) + { + await connection.OpenAsync(); + } + + var attachmentColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + await using (var command = connection.CreateCommand()) + { + command.CommandText = "PRAGMA table_info(MessageAttachments);"; + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + attachmentColumns.Add(reader.GetString(1)); + } + } + + if (!attachmentColumns.Contains("RemoteUrl")) + { + await db.Database.ExecuteSqlRawAsync("ALTER TABLE MessageAttachments ADD COLUMN RemoteUrl TEXT;"); + } + + if (!attachmentColumns.Contains("ExternalId")) + { + await db.Database.ExecuteSqlRawAsync("ALTER TABLE MessageAttachments ADD COLUMN ExternalId TEXT;"); + } + + await db.Database.ExecuteSqlRawAsync(""" + CREATE INDEX IF NOT EXISTS IX_MessageAttachments_MessageId_ExternalId + ON MessageAttachments (MessageId, ExternalId); + """); + + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS MessageReactions ( + Id TEXT NOT NULL CONSTRAINT PK_MessageReactions PRIMARY KEY, + MessageId TEXT NOT NULL, + Emoji TEXT NOT NULL, + ActorKey TEXT NOT NULL, + ActorName TEXT NULL, + CreatedAt TEXT NOT NULL, + CONSTRAINT FK_MessageReactions_Messages_MessageId FOREIGN KEY (MessageId) REFERENCES Messages (Id) ON DELETE CASCADE + ); + """); + await db.Database.ExecuteSqlRawAsync(""" + CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageReactions_MessageId_ActorKey + ON MessageReactions (MessageId, ActorKey); + """); + + await RemoveGenericMediaLabelsAsync(db); +} + +static async Task RemoveGenericMediaLabelsAsync(QMaxDbContext db) +{ + var placeholderMessages = await db.Messages + .Where(message => !db.MessageAttachments.Any(attachment => attachment.MessageId == message.Id)) + .ToListAsync(); + + foreach (var message in placeholderMessages.Where(message => MessageTextSanitizer.IsGenericMediaLabel(message.Text))) + { + db.Messages.Remove(message); + } + + var messages = await db.Messages.ToListAsync(); + foreach (var message in messages) + { + var cleanText = MessageTextSanitizer.CleanMessageText( + message.Text, + await db.MessageAttachments.AnyAsync(attachment => attachment.MessageId == message.Id)); + + if (!string.Equals(message.Text, cleanText, StringComparison.Ordinal)) + { + message.Text = cleanText; + } + } + + var chats = await db.Chats.ToListAsync(); + foreach (var chat in chats) + { + var cleanPreview = MessageTextSanitizer.CleanChatPreview(chat.LastMessagePreview); + if (!string.Equals(chat.LastMessagePreview, cleanPreview, StringComparison.Ordinal)) + { + chat.LastMessagePreview = cleanPreview; + } + } + + var attachments = await db.MessageAttachments.ToListAsync(); + foreach (var attachment in attachments) + { + var normalizedFileName = AttachmentStorageService.NormalizeRemoteFileName( + attachment.OriginalFileName, + attachment.ContentType, + attachment.Kind); + + if (!string.Equals(attachment.OriginalFileName, normalizedFileName, StringComparison.Ordinal)) + { + attachment.OriginalFileName = normalizedFileName; + } + } + + await db.SaveChangesAsync(); +} + +public partial class Program; diff --git a/server/QMax.Api/Properties/launchSettings.json b/server/QMax.Api/Properties/launchSettings.json new file mode 100644 index 0000000..e425246 --- /dev/null +++ b/server/QMax.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5197", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7265;http://localhost:5197", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/server/QMax.Api/QMax.Api.csproj b/server/QMax.Api/QMax.Api.csproj new file mode 100644 index 0000000..69fc845 --- /dev/null +++ b/server/QMax.Api/QMax.Api.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/server/QMax.Api/QMax.Api.http b/server/QMax.Api/QMax.Api.http new file mode 100644 index 0000000..f123ff6 --- /dev/null +++ b/server/QMax.Api/QMax.Api.http @@ -0,0 +1,6 @@ +@QMax.Api_HostAddress = http://localhost:5197 + +GET {{QMax.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/server/QMax.Api/Services/ChatProjectionService.cs b/server/QMax.Api/Services/ChatProjectionService.cs new file mode 100644 index 0000000..aa42130 --- /dev/null +++ b/server/QMax.Api/Services/ChatProjectionService.cs @@ -0,0 +1,75 @@ +using QMax.Api.Contracts; +using QMax.Api.Data.Entities; + +namespace QMax.Api.Services; + +public sealed class ChatProjectionService +{ + public ChatDto ToDto(Chat chat) + { + return new ChatDto( + chat.Id, + chat.ExternalId, + chat.Kind, + chat.Title, + AvatarPath(chat), + CleanPreview(chat.LastMessagePreview), + chat.LastMessageAt, + chat.UnreadCount, + chat.IsPinned, + chat.IsMuted, + chat.IsArchived); + } + + public MessageDto ToDto(Message message) + { + return new MessageDto( + message.Id, + message.ChatId, + message.ExternalId, + message.SenderName, + message.Direction, + MessageTextSanitizer.CleanMessageText(message.Text, message.Attachments.Count != 0), + message.SentAt, + message.EditedAt, + message.DeletedAt, + message.ReplyToMessageId, + message.ForwardedFrom, + message.DeliveryState, + message.Error, + message.Reactions + .GroupBy(x => x.Emoji) + .OrderByDescending(x => x.Count()) + .ThenBy(x => x.Key, StringComparer.Ordinal) + .Select(x => new ReactionDto(x.Key, x.Count(), x.Any(r => r.ActorKey == "self"))) + .ToArray(), + message.Attachments + .OrderBy(x => x.SortOrder) + .Select(x => ToDto(message.ChatId, x)) + .ToArray()); + } + + private static AttachmentDto ToDto(Guid chatId, MessageAttachment attachment) + { + return new AttachmentDto( + attachment.Id, + attachment.OriginalFileName, + attachment.ContentType, + attachment.FileSizeBytes, + $"/api/chats/{chatId}/attachments/{attachment.Id}/content", + attachment.Kind, + attachment.SortOrder); + } + + private static string? CleanPreview(string? value) + { + return MessageTextSanitizer.CleanChatPreview(value); + } + + private static string? AvatarPath(Chat chat) + { + return string.IsNullOrWhiteSpace(chat.AvatarUrl) + ? null + : $"/api/chats/{chat.Id}/avatar"; + } +} diff --git a/server/QMax.Api/Services/FirebasePushNotificationService.cs b/server/QMax.Api/Services/FirebasePushNotificationService.cs new file mode 100644 index 0000000..c636ba0 --- /dev/null +++ b/server/QMax.Api/Services/FirebasePushNotificationService.cs @@ -0,0 +1,324 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using QMax.Api.Configuration; +using QMax.Api.Data; +using QMax.Api.Data.Entities; + +namespace QMax.Api.Services; + +public sealed class FirebasePushNotificationService( + QMaxDbContext db, + IHttpClientFactory httpClientFactory, + IOptions options, + ILogger logger) : IPushNotificationService +{ + private const string FirebaseScope = "https://www.googleapis.com/auth/firebase.messaging"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private static readonly SemaphoreSlim TokenLock = new(1, 1); + private static string? cachedAccessToken; + private static DateTimeOffset cachedAccessTokenExpiresAt; + + public async Task SendNewMessageAsync(Chat chat, Message message, CancellationToken cancellationToken) + { + var qmax = options.Value; + if (!qmax.PushEnabled || chat.IsMuted) + { + return; + } + + var serviceAccount = await LoadServiceAccountAsync(qmax, cancellationToken); + if (serviceAccount is null) + { + logger.LogDebug("FCM push is enabled but Firebase service account is not configured."); + return; + } + + var projectId = FirstNonBlank(qmax.FirebaseProjectId, serviceAccount.ProjectId); + if (string.IsNullOrWhiteSpace(projectId)) + { + logger.LogWarning("FCM push is enabled but Firebase project id is missing."); + return; + } + + var devices = await db.PushDevices + .Where(x => !string.IsNullOrWhiteSpace(x.FirebaseToken)) + .ToArrayAsync(cancellationToken); + if (devices.Length == 0) + { + return; + } + + var accessToken = await GetAccessTokenAsync(serviceAccount, cancellationToken); + var title = chat.Title; + var body = qmax.PushShowPreview + ? BuildMessagePreview(message) + : "Новое сообщение"; + if (IsPresencePreview(body)) + { + return; + } + + var data = new Dictionary + { + ["type"] = "new_message", + ["title"] = title, + ["body"] = body, + ["chatId"] = chat.Id.ToString(), + ["messageId"] = message.Id.ToString(), + ["chatTitle"] = chat.Title + }; + + foreach (var device in devices) + { + await SendToDeviceAsync(projectId, accessToken, device, title, body, data, cancellationToken); + } + + await db.SaveChangesAsync(cancellationToken); + } + + private async Task SendToDeviceAsync( + string projectId, + string accessToken, + PushDevice device, + string title, + string body, + IReadOnlyDictionary data, + CancellationToken cancellationToken) + { + var payload = new + { + message = new + { + token = device.FirebaseToken, + data, + android = new + { + priority = "HIGH" + } + } + }; + + using var request = new HttpRequestMessage( + HttpMethod.Post, + $"https://fcm.googleapis.com/v1/projects/{Uri.EscapeDataString(projectId)}/messages:send"); + request.Headers.Authorization = new("Bearer", accessToken); + request.Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json"); + + using var response = await httpClientFactory.CreateClient().SendAsync(request, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + if (response.IsSuccessStatusCode) + { + return; + } + + if (IsInvalidFirebaseToken(response.StatusCode, responseBody)) + { + logger.LogInformation("Removing invalid FCM token for device {DeviceId}.", device.Id); + db.PushDevices.Remove(device); + return; + } + + logger.LogWarning( + "FCM send failed for device {DeviceId} with {Status}: {Body}", + device.Id, + response.StatusCode, + responseBody); + } + + private static async Task LoadServiceAccountAsync(QMaxOptions qmax, CancellationToken cancellationToken) + { + var json = qmax.FirebaseServiceAccountJson; + if (string.IsNullOrWhiteSpace(json) && !string.IsNullOrWhiteSpace(qmax.FirebaseServiceAccountPath)) + { + if (!File.Exists(qmax.FirebaseServiceAccountPath)) + { + return null; + } + + json = await File.ReadAllTextAsync(qmax.FirebaseServiceAccountPath, cancellationToken); + } + + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + return JsonSerializer.Deserialize(json, JsonOptions); + } + + private async Task GetAccessTokenAsync(FirebaseServiceAccount serviceAccount, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(cachedAccessToken) && + cachedAccessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(2)) + { + return cachedAccessToken; + } + + await TokenLock.WaitAsync(cancellationToken); + try + { + if (!string.IsNullOrWhiteSpace(cachedAccessToken) && + cachedAccessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(2)) + { + return cachedAccessToken; + } + + var assertion = CreateServiceAccountAssertion(serviceAccount); + using var request = new HttpRequestMessage(HttpMethod.Post, serviceAccount.TokenUri) + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer", + ["assertion"] = assertion + }) + }; + + using var response = await httpClientFactory.CreateClient().SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + response.EnsureSuccessStatusCode(); + + var token = JsonSerializer.Deserialize(body, JsonOptions) + ?? throw new InvalidOperationException("Google OAuth token response was empty."); + cachedAccessToken = token.AccessToken; + cachedAccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, token.ExpiresIn - 60)); + return cachedAccessToken; + } + finally + { + TokenLock.Release(); + } + } + + private static string CreateServiceAccountAssertion(FirebaseServiceAccount serviceAccount) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var header = Base64Url(JsonSerializer.SerializeToUtf8Bytes(new + { + alg = "RS256", + typ = "JWT" + })); + var claim = Base64Url(JsonSerializer.SerializeToUtf8Bytes(new + { + iss = serviceAccount.ClientEmail, + scope = FirebaseScope, + aud = serviceAccount.TokenUri, + iat = now, + exp = now + 3600 + })); + + var signingInput = $"{header}.{claim}"; + using var rsa = RSA.Create(); + rsa.ImportFromPem(serviceAccount.PrivateKey); + var signature = rsa.SignData( + Encoding.ASCII.GetBytes(signingInput), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + return $"{signingInput}.{Base64Url(signature)}"; + } + + private static string BuildMessagePreview(Message message) + { + if (!string.IsNullOrWhiteSpace(message.Text)) + { + return message.Text.Length <= 120 + ? message.Text + : message.Text[..117] + "..."; + } + + return message.Attachments.Count > 0 ? "Медиа" : "Новое сообщение"; + } + + private static bool IsPresencePreview(string? value) + { + var text = value?.Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(text) || text.Length > 120) + { + return false; + } + + text = text + .Replace("...", "") + .Replace("\u2026", "") + .Trim(); + + if (text.Contains("\u043f\u0435\u0447\u0430\u0442", StringComparison.Ordinal) || + text.Contains("\u043d\u0430\u0431\u0438\u0440\u0430", StringComparison.Ordinal) || + text.Contains("typing", StringComparison.Ordinal)) + { + return true; + } + + var hasProcessVerb = + text.Contains("\u043e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u0442", StringComparison.Ordinal) || + text.Contains("\u0432\u044b\u0431\u0438\u0440\u0430\u0435\u0442", StringComparison.Ordinal) || + text.Contains("\u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u0442", StringComparison.Ordinal) || + text.Contains("\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u044f\u0435\u0442", StringComparison.Ordinal) || + text.Contains("\u0437\u0430\u043f\u0438\u0441\u044b\u0432\u0430\u0435\u0442", StringComparison.Ordinal) || + text.Contains("is sending", StringComparison.Ordinal) || + text.Contains("sending", StringComparison.Ordinal) || + text.Contains("choosing", StringComparison.Ordinal) || + text.Contains("uploading", StringComparison.Ordinal) || + text.Contains("recording", StringComparison.Ordinal); + if (!hasProcessVerb) + { + return false; + } + + return text.Contains("\u0444\u043e\u0442\u043e", StringComparison.Ordinal) || + text.Contains("\u0438\u0437\u043e\u0431\u0440\u0430\u0436", StringComparison.Ordinal) || + text.Contains("\u043a\u0430\u0440\u0442\u0438\u043d", StringComparison.Ordinal) || + text.Contains("\u0432\u0438\u0434\u0435\u043e", StringComparison.Ordinal) || + text.Contains("\u0441\u0442\u0438\u043a\u0435\u0440", StringComparison.Ordinal) || + text.Contains("\u044d\u043c\u043e\u0434\u0437\u0438", StringComparison.Ordinal) || + text.Contains("\u0433\u043e\u043b\u043e\u0441", StringComparison.Ordinal) || + text.Contains("\u0430\u0443\u0434\u0438\u043e", StringComparison.Ordinal) || + text.Contains("\u0444\u0430\u0439\u043b", StringComparison.Ordinal) || + text.Contains("photo", StringComparison.Ordinal) || + text.Contains("image", StringComparison.Ordinal) || + text.Contains("video", StringComparison.Ordinal) || + text.Contains("sticker", StringComparison.Ordinal) || + text.Contains("emoji", StringComparison.Ordinal) || + text.Contains("voice", StringComparison.Ordinal) || + text.Contains("audio", StringComparison.Ordinal) || + text.Contains("file", StringComparison.Ordinal); + } + + private static bool IsInvalidFirebaseToken(HttpStatusCode statusCode, string responseBody) + { + if (statusCode is not (HttpStatusCode.BadRequest or HttpStatusCode.NotFound)) + { + return false; + } + + return responseBody.Contains("UNREGISTERED", StringComparison.OrdinalIgnoreCase) || + responseBody.Contains("registration token", StringComparison.OrdinalIgnoreCase); + } + + private static string FirstNonBlank(params string?[] values) + { + return values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? ""; + } + + private static string Base64Url(byte[] bytes) + { + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private sealed record FirebaseServiceAccount( + [property: JsonPropertyName("project_id")] string ProjectId, + [property: JsonPropertyName("client_email")] string ClientEmail, + [property: JsonPropertyName("private_key")] string PrivateKey, + [property: JsonPropertyName("token_uri")] string TokenUri); + + private sealed record GoogleAccessTokenResponse( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("expires_in")] int ExpiresIn); +} diff --git a/server/QMax.Api/Services/IPushNotificationService.cs b/server/QMax.Api/Services/IPushNotificationService.cs new file mode 100644 index 0000000..492c986 --- /dev/null +++ b/server/QMax.Api/Services/IPushNotificationService.cs @@ -0,0 +1,8 @@ +using QMax.Api.Data.Entities; + +namespace QMax.Api.Services; + +public interface IPushNotificationService +{ + Task SendNewMessageAsync(Chat chat, Message message, CancellationToken cancellationToken); +} diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs new file mode 100644 index 0000000..2222ad0 --- /dev/null +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -0,0 +1,690 @@ +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using QMax.Api.Data; +using QMax.Api.Data.Entities; +using QMax.Api.Infrastructure.Hubs; +using QMax.Api.Infrastructure.Max; +using QMax.Api.Infrastructure.Storage; +using System.Text; + +namespace QMax.Api.Services; + +public sealed class MaxBridgeSyncService( + IServiceScopeFactory scopeFactory, + IMaxBridgeClient maxBridgeClient, + IHubContext hubContext, + ILogger logger) +{ + public async Task SyncOnceAsync(CancellationToken cancellationToken) + { + try + { + var updates = await maxBridgeClient.FetchUpdatesAsync(cancellationToken); + return await ApplyUpdatesAsync(updates, incrementUnread: true, sendPushNotifications: true, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "MAX sync failed."); + return 0; + } + } + + public async Task SyncChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(externalChatId)) + { + return 0; + } + + try + { + var update = await maxBridgeClient.FetchChatHistoryAsync(externalChatId, cancellationToken); + return update is null + ? 0 + : await ApplyUpdatesAsync([update], incrementUnread: false, sendPushNotifications: false, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "MAX chat history sync failed for {ExternalChatId}.", externalChatId); + return 0; + } + } + + private async Task ApplyUpdatesAsync( + IReadOnlyList updates, + bool incrementUnread, + bool sendPushNotifications, + CancellationToken cancellationToken) + { + if (updates.Count == 0) + { + return 0; + } + + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var projection = scope.ServiceProvider.GetRequiredService(); + var pushNotifications = scope.ServiceProvider.GetRequiredService(); + var storage = scope.ServiceProvider.GetRequiredService(); + var changed = 0; + var createdMessages = new List<(Chat Chat, Message Message)>(); + var updatedMessages = new List<(Chat Chat, Message Message)>(); + var previewPushNotifications = new List<(Chat Chat, Message Message)>(); + + foreach (var update in updates) + { + var chat = await db.Chats + .FirstOrDefaultAsync(x => x.ExternalId == update.ExternalId, cancellationToken); + + if (chat is null && update.ExternalId.StartsWith("dom:", StringComparison.OrdinalIgnoreCase)) + { + chat = await db.Chats + .FirstOrDefaultAsync( + x => x.Title == update.Title && + x.ExternalId != null && + x.ExternalId.StartsWith("dom:"), + cancellationToken); + } + + var chatWasCreated = chat is null; + if (chat is null) + { + chat = new Chat + { + ExternalId = update.ExternalId, + Title = update.Title, + AvatarUrl = update.AvatarUrl, + Kind = ChatKind.MaxDialog + }; + db.Chats.Add(chat); + changed++; + } + + var nextAvatarUrl = string.IsNullOrWhiteSpace(update.AvatarUrl) + ? chat.AvatarUrl + : update.AvatarUrl; + var metadataChanged = false; + if (!string.Equals(chat.ExternalId, update.ExternalId, StringComparison.Ordinal)) + { + chat.ExternalId = update.ExternalId; + metadataChanged = true; + } + + if (!string.Equals(chat.Title, update.Title, StringComparison.Ordinal)) + { + chat.Title = update.Title; + metadataChanged = true; + } + + if (!string.Equals(chat.AvatarUrl, nextAvatarUrl, StringComparison.Ordinal)) + { + chat.AvatarUrl = nextAvatarUrl; + metadataChanged = true; + } + + if (metadataChanged) + { + chat.UpdatedAt = DateTimeOffset.UtcNow; + changed++; + } + + if (ApplyListPreview( + update, + chat, + chatWasCreated, + incrementUnread, + sendPushNotifications, + previewPushNotifications)) + { + changed++; + } + + foreach (var incoming in update.Messages) + { + if (IsPreviewOnlyMessage(incoming.ExternalId)) + { + continue; + } + + if (IsPresencePreview(incoming.Text) && (incoming.Attachments?.Count ?? 0) == 0) + { + continue; + } + + var existing = await db.Messages + .Include(x => x.Attachments) + .FirstOrDefaultAsync(x => x.ExternalId == incoming.ExternalId, cancellationToken); + if (existing is not null) + { + var merge = await MergeExistingMessageAsync( + db, + existing, + incoming, + maxBridgeClient, + storage, + cancellationToken); + if (merge.Changed) + { + updatedMessages.Add((chat, existing)); + if (chat.LastMessageAt is null || existing.SentAt >= chat.LastMessageAt) + { + chat.LastMessagePreview = LastMessagePreview(existing.Text, existing.Attachments.Concat(merge.AddedAttachments)); + chat.LastMessageAt = existing.SentAt; + } + + changed++; + } + + continue; + } + + var message = new Message + { + Chat = chat, + ExternalId = incoming.ExternalId, + SenderExternalId = incoming.SenderExternalId, + SenderName = incoming.SenderName, + Direction = incoming.IsOutgoing ? MessageDirection.Outgoing : MessageDirection.Incoming, + Text = MessageTextSanitizer.CleanMessageText( + incoming.Text, + incoming.Attachments?.Count > 0), + SentAt = incoming.SentAt, + DeliveryState = ResolveDeliveryState(incoming) + }; + + foreach (var incomingAttachment in incoming.Attachments ?? []) + { + var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken); + message.Attachments.Add(attachment); + } + + db.Messages.Add(message); + createdMessages.Add((chat, message)); + chat.LastMessagePreview = LastMessagePreview(incoming.Text, message.Attachments); + chat.LastMessageAt = incoming.SentAt; + if (incrementUnread && !incoming.IsOutgoing) + { + chat.UnreadCount++; + } + changed++; + } + } + + if (changed > 0) + { + await db.SaveChangesAsync(cancellationToken); + foreach (var (chat, message) in createdMessages) + { + var dto = projection.ToDto(message); + await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken); + if (sendPushNotifications && message.Direction == MessageDirection.Incoming) + { + await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken); + } + } + + foreach (var (chat, message) in previewPushNotifications) + { + await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken); + } + + foreach (var (chat, message) in updatedMessages) + { + var updated = await db.Messages + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .FirstOrDefaultAsync(x => x.Id == message.Id, cancellationToken); + if (updated is not null) + { + var dto = projection.ToDto(updated); + await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageUpdated", dto, cancellationToken); + } + } + + await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); + } + + return changed; + } + + private static bool ApplyListPreview( + MaxChatUpdate update, + Chat chat, + bool chatWasCreated, + bool incrementUnread, + bool sendPushNotifications, + ICollection<(Chat Chat, Message Message)> previewPushNotifications) + { + var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview); + if (string.IsNullOrWhiteSpace(preview)) + { + return false; + } + + if (IsPresencePreview(preview)) + { + return false; + } + + var previousPreview = chat.LastMessagePreview; + if (string.Equals(previousPreview, preview, StringComparison.Ordinal)) + { + return false; + } + + var previewAt = ResolveListPreviewAt(update); + chat.LastMessagePreview = preview; + chat.LastMessageAt = previewAt; + chat.UpdatedAt = DateTimeOffset.UtcNow; + + var shouldNotify = !chatWasCreated && + !string.IsNullOrWhiteSpace(previousPreview) && + update.LastMessageIsOutgoing != true && + update.Messages.Count == 0 && + IsFreshListPreview(update, previewAt); + + if (incrementUnread && shouldNotify) + { + chat.UnreadCount++; + } + + if (sendPushNotifications && shouldNotify) + { + previewPushNotifications.Add(( + chat, + new Message + { + Id = Guid.NewGuid(), + ChatId = chat.Id, + Chat = chat, + Direction = MessageDirection.Incoming, + Text = preview, + SentAt = previewAt, + DeliveryState = MessageDeliveryState.Delivered, + SenderExternalId = chat.ExternalId, + SenderName = chat.Title + })); + } + + return true; + } + + private static DateTimeOffset ResolveListPreviewAt(MaxChatUpdate update) + { + return TryParseMoscowListTime(update.LastMessageTimeText, out var parsed) + ? parsed + : update.LastMessageAt ?? update.UpdatedAt; + } + + private static bool IsFreshListPreview(MaxChatUpdate update, DateTimeOffset previewAt) + { + if (string.IsNullOrWhiteSpace(update.LastMessageTimeText)) + { + return false; + } + + var now = DateTimeOffset.UtcNow; + return previewAt >= now.AddMinutes(-15) && previewAt <= now.AddMinutes(10); + } + + private static bool TryParseMoscowListTime(string? value, out DateTimeOffset parsed) + { + parsed = default; + var text = value?.Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + var match = System.Text.RegularExpressions.Regex.Match(text, @"\b(?\d{1,2}):(?\d{2})\b"); + if (!match.Success) + { + return false; + } + + if (!int.TryParse(match.Groups["hour"].Value, out var hour) || + !int.TryParse(match.Groups["minute"].Value, out var minute) || + hour is < 0 or > 23 || + minute is < 0 or > 59) + { + return false; + } + + var zone = GetMoscowTimeZone(); + var nowMoscow = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, zone); + var local = new DateTime(nowMoscow.Year, nowMoscow.Month, nowMoscow.Day, hour, minute, 0, DateTimeKind.Unspecified); + parsed = new DateTimeOffset(local, zone.GetUtcOffset(local)); + if (parsed > DateTimeOffset.UtcNow.AddMinutes(10)) + { + parsed = parsed.AddDays(-1); + } + + return true; + } + + private static TimeZoneInfo GetMoscowTimeZone() + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById("Europe/Moscow"); + } + catch (TimeZoneNotFoundException) + { + return TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"); + } + } + + private static bool IsPreviewOnlyMessage(string? externalId) + { + return externalId?.StartsWith("dommsg:", StringComparison.OrdinalIgnoreCase) == true; + } + + private static bool IsPresencePreview(string? value) + { + var text = value?.Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(text) || text.Length > 120) + { + return false; + } + + text = text + .Replace("...", "") + .Replace("\u2026", "") + .Trim(); + + if (text.Contains("\u043f\u0435\u0447\u0430\u0442", StringComparison.Ordinal) || + text.Contains("\u043d\u0430\u0431\u0438\u0440\u0430", StringComparison.Ordinal) || + text.Contains("typing", StringComparison.Ordinal)) + { + return true; + } + + var hasProcessVerb = + text.Contains("\u043e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u0442", StringComparison.Ordinal) || + text.Contains("\u0432\u044b\u0431\u0438\u0440\u0430\u0435\u0442", StringComparison.Ordinal) || + text.Contains("\u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u0442", StringComparison.Ordinal) || + text.Contains("\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u044f\u0435\u0442", StringComparison.Ordinal) || + text.Contains("\u0437\u0430\u043f\u0438\u0441\u044b\u0432\u0430\u0435\u0442", StringComparison.Ordinal) || + text.Contains("is sending", StringComparison.Ordinal) || + text.Contains("sending", StringComparison.Ordinal) || + text.Contains("choosing", StringComparison.Ordinal) || + text.Contains("uploading", StringComparison.Ordinal) || + text.Contains("recording", StringComparison.Ordinal); + if (!hasProcessVerb) + { + return false; + } + + return text.Contains("\u0444\u043e\u0442\u043e", StringComparison.Ordinal) || + text.Contains("\u0438\u0437\u043e\u0431\u0440\u0430\u0436", StringComparison.Ordinal) || + text.Contains("\u043a\u0430\u0440\u0442\u0438\u043d", StringComparison.Ordinal) || + text.Contains("\u0432\u0438\u0434\u0435\u043e", StringComparison.Ordinal) || + text.Contains("\u0441\u0442\u0438\u043a\u0435\u0440", StringComparison.Ordinal) || + text.Contains("\u044d\u043c\u043e\u0434\u0437\u0438", StringComparison.Ordinal) || + text.Contains("\u0433\u043e\u043b\u043e\u0441", StringComparison.Ordinal) || + text.Contains("\u0430\u0443\u0434\u0438\u043e", StringComparison.Ordinal) || + text.Contains("\u0444\u0430\u0439\u043b", StringComparison.Ordinal) || + text.Contains("photo", StringComparison.Ordinal) || + text.Contains("image", StringComparison.Ordinal) || + text.Contains("video", StringComparison.Ordinal) || + text.Contains("sticker", StringComparison.Ordinal) || + text.Contains("emoji", StringComparison.Ordinal) || + text.Contains("voice", StringComparison.Ordinal) || + text.Contains("audio", StringComparison.Ordinal) || + text.Contains("file", StringComparison.Ordinal); + } + + private sealed record MessageMergeResult(bool Changed, IReadOnlyList AddedAttachments); + + private static async Task MergeExistingMessageAsync( + QMaxDbContext db, + Message message, + MaxMessageUpdate incoming, + IMaxBridgeClient maxBridgeClient, + IAttachmentStorageService storage, + CancellationToken cancellationToken) + { + var changed = false; + var metadataChanged = false; + var addedAttachments = new List(); + var knownAttachments = message.Attachments.ToList(); + var incomingAttachments = incoming.Attachments ?? []; + var cleanText = MessageTextSanitizer.CleanMessageText( + incoming.Text, + incomingAttachments.Count > 0 || message.Attachments.Count > 0); + + if (!string.Equals(message.Text, cleanText, StringComparison.Ordinal)) + { + message.Text = cleanText; + changed = true; + metadataChanged = true; + } + + var senderName = string.IsNullOrWhiteSpace(incoming.SenderName) ? null : incoming.SenderName; + if (!string.Equals(message.SenderName, senderName, StringComparison.Ordinal)) + { + message.SenderName = senderName; + changed = true; + metadataChanged = true; + } + + if (!string.Equals(message.SenderExternalId, incoming.SenderExternalId, StringComparison.Ordinal)) + { + message.SenderExternalId = incoming.SenderExternalId; + changed = true; + metadataChanged = true; + } + + var direction = incoming.IsOutgoing ? MessageDirection.Outgoing : MessageDirection.Incoming; + if (message.Direction != direction) + { + message.Direction = direction; + changed = true; + metadataChanged = true; + } + + if (message.SentAt != incoming.SentAt) + { + message.SentAt = incoming.SentAt; + changed = true; + metadataChanged = true; + } + + var deliveryState = ResolveDeliveryState(incoming); + if (message.DeliveryState != deliveryState) + { + message.DeliveryState = deliveryState; + changed = true; + metadataChanged = true; + } + + if (metadataChanged) + { + await db.Messages + .Where(x => x.Id == message.Id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Text, cleanText) + .SetProperty(x => x.SenderName, senderName) + .SetProperty(x => x.SenderExternalId, incoming.SenderExternalId) + .SetProperty(x => x.Direction, direction) + .SetProperty(x => x.SentAt, incoming.SentAt) + .SetProperty(x => x.DeliveryState, deliveryState), + cancellationToken); + db.Entry(message).State = EntityState.Unchanged; + } + + foreach (var incomingAttachment in incomingAttachments) + { + if (HasAttachment(knownAttachments, incomingAttachment)) + { + continue; + } + + var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken); + attachment.MessageId = message.Id; + db.MessageAttachments.Add(attachment); + knownAttachments.Add(attachment); + addedAttachments.Add(attachment); + changed = true; + } + + return new MessageMergeResult(changed, addedAttachments); + } + + private static async Task CreateAttachmentAsync( + MaxAttachmentUpdate incoming, + IMaxBridgeClient maxBridgeClient, + IAttachmentStorageService storage, + CancellationToken cancellationToken) + { + var kind = ResolveKind(incoming); + var remoteUrl = incoming.RemoteUrl; + if (!string.IsNullOrWhiteSpace(incoming.TextContent)) + { + var contentType = string.IsNullOrWhiteSpace(incoming.ContentType) + ? "text/vcard; charset=utf-8" + : incoming.ContentType; + var bytes = Encoding.UTF8.GetBytes(incoming.TextContent); + await using var stream = new MemoryStream(bytes); + var stored = await storage.SaveRemoteAsync( + incoming.FileName, + contentType, + stream, + bytes.Length, + kind, + cancellationToken); + + return new MessageAttachment + { + ExternalId = incoming.ExternalId, + OriginalFileName = stored.OriginalFileName, + StorageFileName = stored.StorageFileName, + ContentType = stored.ContentType, + FileSizeBytes = stored.FileSizeBytes, + Sha256 = stored.Sha256, + Kind = stored.Kind, + SortOrder = incoming.SortOrder, + RemoteUrl = remoteUrl + }; + } + + if (!string.IsNullOrWhiteSpace(remoteUrl)) + { + try + { + var download = await maxBridgeClient.DownloadMediaAsync(remoteUrl, cancellationToken); + if (download is not null) + { + await using (download.Stream) + { + var stored = await storage.SaveRemoteAsync( + incoming.FileName, + download.ContentType, + download.Stream, + download.ContentLength ?? incoming.FileSizeBytes, + kind, + cancellationToken); + + return new MessageAttachment + { + ExternalId = incoming.ExternalId, + OriginalFileName = stored.OriginalFileName, + StorageFileName = stored.StorageFileName, + ContentType = stored.ContentType, + FileSizeBytes = stored.FileSizeBytes, + Sha256 = stored.Sha256, + Kind = stored.Kind, + SortOrder = incoming.SortOrder, + RemoteUrl = remoteUrl + }; + } + } + } + catch + { + // Keep the message visible even if a specific remote media item cannot be cached. + } + } + + return new MessageAttachment + { + ExternalId = incoming.ExternalId, + OriginalFileName = AttachmentStorageService.NormalizeRemoteFileName(incoming.FileName, incoming.ContentType, kind), + StorageFileName = $"remote-{Guid.NewGuid():N}", + ContentType = string.IsNullOrWhiteSpace(incoming.ContentType) ? "application/octet-stream" : incoming.ContentType, + FileSizeBytes = incoming.FileSizeBytes ?? 0, + Kind = kind ?? AttachmentKind.File, + SortOrder = incoming.SortOrder, + RemoteUrl = remoteUrl + }; + } + + private static bool HasAttachment(IEnumerable attachments, MaxAttachmentUpdate incoming) + { + if (!string.IsNullOrWhiteSpace(incoming.ExternalId) && + attachments.Any(x => string.Equals(x.ExternalId, incoming.ExternalId, StringComparison.Ordinal))) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(incoming.RemoteUrl) && + attachments.Any(x => string.Equals(x.RemoteUrl, incoming.RemoteUrl, StringComparison.Ordinal))) + { + return true; + } + + var kind = ResolveKind(incoming) ?? AttachmentKind.File; + var normalizedFileName = AttachmentStorageService.NormalizeRemoteFileName(incoming.FileName, incoming.ContentType, kind); + return attachments.Any(x => + x.SortOrder == incoming.SortOrder && + x.Kind == kind && + string.Equals(x.OriginalFileName, normalizedFileName, StringComparison.OrdinalIgnoreCase)); + } + + private static AttachmentKind? ResolveKind(MaxAttachmentUpdate attachment) + { + if (!string.IsNullOrWhiteSpace(attachment.Kind) && + Enum.TryParse(attachment.Kind, ignoreCase: true, out var parsed)) + { + return parsed; + } + + var extension = Path.GetExtension(attachment.FileName); + return AttachmentStorageService.GuessKind(attachment.ContentType, extension); + } + + private static MessageDeliveryState ResolveDeliveryState(MaxMessageUpdate incoming) + { + if (!incoming.IsOutgoing) + { + return MessageDeliveryState.Delivered; + } + + return Enum.TryParse(incoming.DeliveryState, ignoreCase: true, out var parsed) + ? parsed + : MessageDeliveryState.Sent; + } + + private static string? LastMessagePreview(string? text, IEnumerable attachments) + { + var orderedAttachments = attachments.OrderBy(x => x.SortOrder).ToArray(); + if (!string.IsNullOrWhiteSpace(text) && + (orderedAttachments.Length == 0 || !MessageTextSanitizer.IsGenericAttachmentLabel(text))) + { + return MessageTextSanitizer.CleanChatPreview(text); + } + + var first = orderedAttachments.FirstOrDefault(); + return first?.Kind switch + { + AttachmentKind.Image => "\u041c\u0435\u0434\u0438\u0430", + AttachmentKind.Gif => "\u041c\u0435\u0434\u0438\u0430", + AttachmentKind.Video => "\u041c\u0435\u0434\u0438\u0430", + AttachmentKind.VoiceNote => "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435", + AttachmentKind.Sticker => "\u041c\u0435\u0434\u0438\u0430", + AttachmentKind.Contact => "\u041a\u043e\u043d\u0442\u0430\u043a\u0442", + AttachmentKind.File => first.OriginalFileName, + _ => null + }; + } +} diff --git a/server/QMax.Api/Services/MaxSyncWorker.cs b/server/QMax.Api/Services/MaxSyncWorker.cs new file mode 100644 index 0000000..3d8fe93 --- /dev/null +++ b/server/QMax.Api/Services/MaxSyncWorker.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Options; +using QMax.Api.Configuration; + +namespace QMax.Api.Services; + +public sealed class MaxSyncWorker( + IOptions options, + MaxBridgeSyncService syncService, + ILogger logger) : BackgroundService +{ + private readonly QMaxOptions _options = options.Value; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var delay = TimeSpan.FromSeconds(Math.Max(3, _options.MaxPollIntervalSeconds)); + using var timer = new PeriodicTimer(delay); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await syncService.SyncOnceAsync(stoppingToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "QMAX background sync tick failed."); + } + + try + { + await timer.WaitForNextTickAsync(stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + } +} diff --git a/server/QMax.Api/Services/MessageTextSanitizer.cs b/server/QMax.Api/Services/MessageTextSanitizer.cs new file mode 100644 index 0000000..d1f43a9 --- /dev/null +++ b/server/QMax.Api/Services/MessageTextSanitizer.cs @@ -0,0 +1,98 @@ +namespace QMax.Api.Services; + +public static class MessageTextSanitizer +{ + public static string? CleanChatPreview(string? value) + { + if (IsGenericMediaLabel(value)) + { + return "\u041c\u0435\u0434\u0438\u0430"; + } + + return IsQuestionMarkArtifact(value) + ? "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435" + : value; + } + + public static string? CleanMessageText(string? value, bool hasAttachments) + { + if (hasAttachments && IsGenericAttachmentLabel(value)) + { + return null; + } + + if (!IsQuestionMarkArtifact(value)) + { + return value; + } + + return hasAttachments + ? null + : "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435"; + } + + public static bool IsGenericMediaLabel(string? value) + { + return value?.Trim().ToLowerInvariant() switch + { + "\u0444\u043e\u0442\u043e" => true, + "photo" => true, + "image" => true, + "gif" => true, + "\u0432\u0438\u0434\u0435\u043e" => true, + "video" => true, + "\u0430\u0443\u0434\u0438\u043e" => true, + "audio" => true, + "\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435" => true, + "voice" => true, + "\u0441\u0442\u0438\u043a\u0435\u0440" => true, + "sticker" => true, + "\u044d\u043c\u043e\u0434\u0437\u0438" => true, + "emoji" => true, + "\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u0435\u043d\u043d\u044b\u0435 \u0444\u043e\u0442\u043e" => true, + "\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u0451\u043d\u043d\u044b\u0435 \u0444\u043e\u0442\u043e" => true, + "attached photos" => true, + _ => false + }; + } + + public static bool IsGenericAttachmentLabel(string? value) + { + if (IsGenericMediaLabel(value)) + { + return true; + } + + return value?.Trim().ToLowerInvariant() switch + { + "\u0441\u043a\u0430\u0447\u0430\u0442\u044c" => true, + "download" => true, + "\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c" => true, + "file" => true, + "\u0444\u0430\u0439\u043b" => true, + "document" => true, + "\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442" => true, + "contact" => true, + "\u043a\u043e\u043d\u0442\u0430\u043a\u0442" => true, + _ => false + }; + } + + public static bool IsQuestionMarkArtifact(string? value) + { + var text = value?.Trim(); + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + var questionMarks = text.Count(c => c is '?' or '\uFFFD'); + if (questionMarks < 4) + { + return false; + } + + var meaningfulCharacters = text.Count(c => char.IsLetterOrDigit(c) && c != '?'); + return meaningfulCharacters == 0; + } +} diff --git a/server/QMax.Api/appsettings.Development.json b/server/QMax.Api/appsettings.Development.json new file mode 100644 index 0000000..1e293c4 --- /dev/null +++ b/server/QMax.Api/appsettings.Development.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + }, + "QMax": { + "PairingCode": "qmax-local-dev", + "MaxMode": "Mock" + } +} diff --git a/server/QMax.Api/appsettings.json b/server/QMax.Api/appsettings.json new file mode 100644 index 0000000..0d2f938 --- /dev/null +++ b/server/QMax.Api/appsettings.json @@ -0,0 +1,32 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "QMax": { + "PublicBaseUrl": "https://qmax.kusoft.xyz", + "DatabasePath": "data/qmax.db", + "StoragePath": "data/storage", + "JwtIssuer": "QMAX", + "JwtAudience": "QMAX.Android", + "JwtSecret": "", + "PairingCode": "", + "MaxPhoneNumber": "", + "MaxMode": "Worker", + "MaxWorkerBaseUrl": "http://qmax-max-worker:3001", + "MaxUserDataPath": "data/max-profile", + "MaxHeadless": true, + "MaxPollIntervalSeconds": 6, + "MaxUploadBytes": 26214400, + "CorsAllowedOrigins": "", + "ReleasesPath": "data/releases", + "PushEnabled": true, + "PushShowPreview": true, + "FirebaseProjectId": "", + "FirebaseServiceAccountPath": "", + "FirebaseServiceAccountJson": "" + } +} diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs new file mode 100644 index 0000000..12a775d --- /dev/null +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -0,0 +1,1041 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.TestHost; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +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.Max; +using QMax.Api.Infrastructure.Storage; +using QMax.Api.Services; + +namespace QMax.Tests; + +public sealed class ApiSmokeTests : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + private readonly string _databasePath = Path.Combine(Path.GetTempPath(), $"qmax-test-{Guid.NewGuid():N}.db"); + private readonly WebApplicationFactory _factory; + + public ApiSmokeTests() + { + _factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["QMax:DatabasePath"] = _databasePath, + ["QMax:StoragePath"] = Path.Combine(Path.GetTempPath(), $"qmax-storage-{Guid.NewGuid():N}"), + ["QMax:PairingCode"] = "test-pairing", + ["QMax:JwtSecret"] = "development-only-qmax-secret-change-before-production", + ["QMax:MaxMode"] = "Mock", + ["QMax:MaxPollIntervalSeconds"] = "600" + }); + }); + }); + } + + [Fact] + public async Task LoginSyncAndSendMessageFlowWorks() + { + using var client = _factory.CreateClient(); + + var authResponse = await client.PostAsJsonAsync( + "/api/auth/device/login", + new DeviceLoginRequest("test-pairing", "xunit")); + + await AssertStatusAsync(HttpStatusCode.OK, authResponse); + var auth = await authResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(auth); + Assert.False(string.IsNullOrWhiteSpace(auth!.AccessToken)); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth.AccessToken); + + var sync = await client.PostAsync("/api/max/sync", null); + await AssertStatusAsync(HttpStatusCode.OK, sync); + + var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); + Assert.NotNull(chats); + Assert.Contains(chats!, x => x.ExternalId == "mock-qmax"); + + var created = await client.PostAsJsonAsync( + "/api/chats/direct", + new CreateDirectChatRequest("mock-direct", "Smoke Direct")); + await AssertStatusAsync(HttpStatusCode.OK, created); + var chat = await created.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(chat); + + var messageResponse = await client.PostAsJsonAsync( + $"/api/chats/{chat!.Id}/messages", + new SendMessageRequest("hello from test")); + await AssertStatusAsync(HttpStatusCode.OK, messageResponse); + var message = await messageResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(message); + Assert.Equal("hello from test", message!.Text); + Assert.Equal(MessageDeliveryState.Sent, message.DeliveryState); + + var replyResponse = await client.PostAsJsonAsync( + $"/api/chats/{chat.Id}/messages", + new SendMessageRequest("reply from test", message.Id)); + await AssertStatusAsync(HttpStatusCode.OK, replyResponse); + var reply = await replyResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(reply); + Assert.Equal(message.Id, reply!.ReplyToMessageId); + } + + [Fact] + public async Task SyncMergesLateDiscoveredAttachmentsIntoExistingMessage() + { + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + var firstSync = await client.PostAsync("/api/max/sync", null); + await AssertStatusAsync(HttpStatusCode.OK, firstSync); + + var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); + var chat = Assert.Single(chats!, x => x.ExternalId == LateAttachmentMaxBridgeClient.ChatExternalId); + + var firstMessages = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages", JsonOptions); + var placeholder = Assert.Single(firstMessages!); + + var secondSync = await client.PostAsync("/api/max/sync", null); + await AssertStatusAsync(HttpStatusCode.OK, secondSync); + + var mergedMessages = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages", JsonOptions); + var merged = Assert.Single(mergedMessages!); + Assert.Equal(placeholder.Id, merged.Id); + Assert.Null(merged.Text); + Assert.Null(merged.EditedAt); + var attachment = Assert.Single(merged.Attachments); + Assert.Equal(AttachmentKind.Image, attachment.Kind); + Assert.Equal("max-image.png", attachment.FileName); + + var download = await client.GetAsync(attachment.DownloadPath); + await AssertStatusAsync(HttpStatusCode.OK, download); + Assert.Equal(LateAttachmentMaxBridgeClient.MediaBytes, await download.Content.ReadAsByteArrayAsync()); + + var updatedChats = await client.GetFromJsonAsync("/api/chats", JsonOptions); + var updatedChat = Assert.Single(updatedChats!, x => x.Id == chat.Id); + Assert.Equal("\u041c\u0435\u0434\u0438\u0430", updatedChat.LastMessagePreview); + } + + [Fact] + public async Task DownloadingRemotePlaceholderCachesMediaInStorage() + { + var bridge = new DeferredRemoteMediaMaxBridgeClient(); + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(bridge); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + Guid chatId; + Guid attachmentId; + var manualExternalId = $"manual-deferred-media-{Guid.NewGuid():N}"; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var chat = new Chat + { + ExternalId = manualExternalId, + Title = "Deferred Media", + Kind = ChatKind.MaxDialog, + LastMessagePreview = "\u041c\u0435\u0434\u0438\u0430", + LastMessageAt = DateTimeOffset.Parse("2026-07-02T12:05:00+00:00") + }; + var message = new Message + { + Chat = chat, + ExternalId = "deferred-message-1", + SenderName = "MAX Sender", + Direction = MessageDirection.Incoming, + SentAt = DateTimeOffset.Parse("2026-07-02T12:05:00+00:00"), + DeliveryState = MessageDeliveryState.Delivered + }; + var attachment = new MessageAttachment + { + Message = message, + ExternalId = "deferred-media-1", + OriginalFileName = "\u0424\u043e\u0442\u043e", + StorageFileName = $"remote-{Guid.NewGuid():N}", + ContentType = "image/png", + FileSizeBytes = 0, + Kind = AttachmentKind.Image, + SortOrder = 0, + RemoteUrl = "https://max.test/media/deferred-media-1.png" + }; + db.Chats.Add(chat); + db.Messages.Add(message); + db.MessageAttachments.Add(attachment); + await db.SaveChangesAsync(); + chatId = chat.Id; + attachmentId = attachment.Id; + } + + bridge.AllowMediaDownload = true; + var download = await client.GetAsync($"/api/chats/{chatId}/attachments/{attachmentId}/content"); + await AssertStatusAsync(HttpStatusCode.OK, download); + Assert.Equal(DeferredRemoteMediaMaxBridgeClient.MediaBytes, await download.Content.ReadAsByteArrayAsync()); + + var afterDownload = await client.GetFromJsonAsync($"/api/chats/{chatId}/messages", JsonOptions); + var cached = Assert.Single(Assert.Single(afterDownload!).Attachments); + Assert.Equal(DeferredRemoteMediaMaxBridgeClient.MediaBytes.Length, cached.FileSizeBytes); + Assert.Equal("image/png", cached.ContentType); + Assert.Equal("max-image.png", cached.FileName); + } + + [Fact] + public async Task RemoteMediaStorageSavesVoiceNoteAtomically() + { + var storagePath = Path.Combine(Path.GetTempPath(), $"qmax-remote-media-{Guid.NewGuid():N}"); + var storage = new AttachmentStorageService(Options.Create(new QMaxOptions + { + StoragePath = storagePath, + MaxUploadBytes = 1024 * 1024 + })); + + var bytes = new byte[] { 0x00, 0x01, 0x02, 0x03, 0x04 }; + await using var stream = new MemoryStream(bytes); + + var stored = await storage.SaveRemoteAsync( + "voice.m4a", + "audio/mp4", + stream, + bytes.Length, + null, + CancellationToken.None); + + var finalPath = storage.GetPath(stored.StorageFileName); + Assert.True(File.Exists(finalPath)); + Assert.False(File.Exists(finalPath + ".part")); + Assert.Equal(bytes.Length, stored.FileSizeBytes); + Assert.Equal(AttachmentKind.VoiceNote, stored.Kind); + Assert.Equal(bytes, await File.ReadAllBytesAsync(finalPath)); + + Directory.Delete(storagePath, recursive: true); + } + + [Fact] + public async Task UploadAttachmentFlowStoresDownloadsAndMarksSent() + { + using var client = _factory.CreateClient(); + await LoginAsync(client); + + var created = await client.PostAsJsonAsync( + "/api/chats/direct", + new CreateDirectChatRequest("mock-upload-chat", "Upload Smoke")); + await AssertStatusAsync(HttpStatusCode.OK, created); + var chat = await created.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(chat); + + var bytes = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01, 0x02, 0x03 }; + using var form = new MultipartFormDataContent(); + using var fileContent = new ByteArrayContent(bytes); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + form.Add(fileContent, "file", "sample.png"); + form.Add(new StringContent("caption"), "caption"); + var baseMessageResponse = await client.PostAsJsonAsync( + $"/api/chats/{chat!.Id}/messages", + new SendMessageRequest("attachment reply source")); + await AssertStatusAsync(HttpStatusCode.OK, baseMessageResponse); + var baseMessage = await baseMessageResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(baseMessage); + form.Add(new StringContent(baseMessage!.Id.ToString()), "replyToMessageId"); + + var upload = await client.PostAsync($"/api/chats/{chat.Id}/attachments", form); + await AssertStatusAsync(HttpStatusCode.OK, upload); + var message = await upload.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(message); + Assert.Equal(MessageDeliveryState.Sent, message!.DeliveryState); + Assert.Equal(baseMessage.Id, message.ReplyToMessageId); + var attachment = Assert.Single(message.Attachments); + Assert.Equal(AttachmentKind.Image, attachment.Kind); + Assert.Equal("sample.png", attachment.FileName); + + var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); + var updated = Assert.Single(chats!, x => x.Id == chat.Id); + Assert.Equal("caption", updated.LastMessagePreview); + + var download = await client.GetAsync(attachment.DownloadPath); + await AssertStatusAsync(HttpStatusCode.OK, download); + Assert.Equal(bytes, await download.Content.ReadAsByteArrayAsync()); + } + + [Fact] + public async Task ForwardMessageFlowCopiesTextAndAttachments() + { + using var client = _factory.CreateClient(); + await LoginAsync(client); + + var source = await CreateDirectChatAsync(client, "mock-forward-source", "Forward Source"); + var target = await CreateDirectChatAsync(client, "mock-forward-target", "Forward Target"); + + var textResponse = await client.PostAsJsonAsync( + $"/api/chats/{source.Id}/messages", + new SendMessageRequest("forward text marker")); + await AssertStatusAsync(HttpStatusCode.OK, textResponse); + var sourceMessage = await textResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(sourceMessage); + + var textForwardResponse = await client.PostAsJsonAsync( + $"/api/chats/{source.Id}/messages/{sourceMessage!.Id}/forward", + new ForwardMessageRequest(target.Id)); + await AssertStatusAsync(HttpStatusCode.OK, textForwardResponse); + var forwardedText = await textForwardResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(forwardedText); + Assert.Equal(target.Id, forwardedText!.ChatId); + Assert.Equal("forward text marker", forwardedText.Text); + Assert.Equal("You", forwardedText.ForwardedFrom); + Assert.Equal(MessageDeliveryState.Sent, forwardedText.DeliveryState); + + var bytes = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x44, 0x41, 0x54, 0x41 }; + using var form = new MultipartFormDataContent(); + using var fileContent = new ByteArrayContent(bytes); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + form.Add(fileContent, "file", "forward.png"); + form.Add(new StringContent("forward caption"), "caption"); + var upload = await client.PostAsync($"/api/chats/{source.Id}/attachments", form); + await AssertStatusAsync(HttpStatusCode.OK, upload); + var sourceAttachmentMessage = await upload.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(sourceAttachmentMessage); + + var attachmentForwardResponse = await client.PostAsJsonAsync( + $"/api/chats/{source.Id}/messages/{sourceAttachmentMessage!.Id}/forward", + new ForwardMessageRequest(target.Id)); + await AssertStatusAsync(HttpStatusCode.OK, attachmentForwardResponse); + var forwardedAttachmentMessage = await attachmentForwardResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(forwardedAttachmentMessage); + Assert.Equal(target.Id, forwardedAttachmentMessage!.ChatId); + Assert.Equal("forward caption", forwardedAttachmentMessage.Text); + Assert.Equal("You", forwardedAttachmentMessage.ForwardedFrom); + Assert.Equal(MessageDeliveryState.Sent, forwardedAttachmentMessage.DeliveryState); + var forwardedAttachment = Assert.Single(forwardedAttachmentMessage.Attachments); + Assert.Equal("forward.png", forwardedAttachment.FileName); + Assert.Equal(AttachmentKind.Image, forwardedAttachment.Kind); + Assert.NotEqual(sourceAttachmentMessage.Attachments.Single().Id, forwardedAttachment.Id); + + var download = await client.GetAsync(forwardedAttachment.DownloadPath); + await AssertStatusAsync(HttpStatusCode.OK, download); + Assert.Equal(bytes, await download.Content.ReadAsByteArrayAsync()); + + var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); + var updatedTarget = Assert.Single(chats!, x => x.Id == target.Id); + Assert.Equal("forward caption", updatedTarget.LastMessagePreview); + } + + [Fact] + public async Task EditAndDeleteMessageFlowUpdatesHistoryAndChatPreview() + { + using var client = _factory.CreateClient(); + await LoginAsync(client); + + var chat = await CreateDirectChatAsync(client, "mock-edit-delete-chat", "Edit Delete Smoke"); + + var firstResponse = await client.PostAsJsonAsync( + $"/api/chats/{chat.Id}/messages", + new SendMessageRequest("first message")); + await AssertStatusAsync(HttpStatusCode.OK, firstResponse); + var first = await firstResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(first); + + var secondResponse = await client.PostAsJsonAsync( + $"/api/chats/{chat.Id}/messages", + new SendMessageRequest("second message")); + await AssertStatusAsync(HttpStatusCode.OK, secondResponse); + var second = await secondResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(second); + + using var editRequest = new HttpRequestMessage(HttpMethod.Patch, $"/api/chats/{chat.Id}/messages/{second!.Id}") + { + Content = JsonContent.Create(new EditMessageRequest("edited second"), options: JsonOptions) + }; + var editResponse = await client.SendAsync(editRequest); + await AssertStatusAsync(HttpStatusCode.OK, editResponse); + var edited = await editResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(edited); + Assert.Equal("edited second", edited!.Text); + Assert.NotNull(edited.EditedAt); + + var chatsAfterEdit = await client.GetFromJsonAsync("/api/chats", JsonOptions); + var editedChat = Assert.Single(chatsAfterEdit!, x => x.Id == chat.Id); + Assert.Equal("edited second", editedChat.LastMessagePreview); + + var deleteResponse = await client.DeleteAsync($"/api/chats/{chat.Id}/messages/{second.Id}"); + await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse); + + var messages = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages", JsonOptions); + Assert.NotNull(messages); + Assert.Contains(messages!, x => x.Id == first!.Id); + Assert.DoesNotContain(messages!, x => x.Id == second.Id); + + var chatsAfterDelete = await client.GetFromJsonAsync("/api/chats", JsonOptions); + var updatedChat = Assert.Single(chatsAfterDelete!, x => x.Id == chat.Id); + Assert.Equal("first message", updatedChat.LastMessagePreview); + } + + [Fact] + public async Task ReactionFlowSetsReplacesAndClearsOwnReaction() + { + using var client = _factory.CreateClient(); + await LoginAsync(client); + + var chat = await CreateDirectChatAsync(client, "mock-reaction-chat", "Reaction Smoke"); + var messageResponse = await client.PostAsJsonAsync( + $"/api/chats/{chat.Id}/messages", + new SendMessageRequest("reaction target")); + await AssertStatusAsync(HttpStatusCode.OK, messageResponse); + var message = await messageResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(message); + + var setResponse = await client.PutAsJsonAsync( + $"/api/chats/{chat.Id}/messages/{message!.Id}/reaction", + new SetReactionRequest("\ud83d\udc4d"), + JsonOptions); + await AssertStatusAsync(HttpStatusCode.OK, setResponse); + var reacted = await setResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(reacted); + var thumbsUp = Assert.Single(reacted!.Reactions); + Assert.Equal("\ud83d\udc4d", thumbsUp.Emoji); + Assert.Equal(1, thumbsUp.Count); + Assert.True(thumbsUp.ReactedByMe); + + var replaceResponse = await client.PutAsJsonAsync( + $"/api/chats/{chat.Id}/messages/{message.Id}/reaction", + new SetReactionRequest("\u2764\ufe0f"), + JsonOptions); + await AssertStatusAsync(HttpStatusCode.OK, replaceResponse); + var replaced = await replaceResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(replaced); + var heart = Assert.Single(replaced!.Reactions); + Assert.Equal("\u2764\ufe0f", heart.Emoji); + + var clearResponse = await client.DeleteAsync($"/api/chats/{chat.Id}/messages/{message.Id}/reaction"); + await AssertStatusAsync(HttpStatusCode.OK, clearResponse); + var cleared = await clearResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(cleared); + Assert.Empty(cleared!.Reactions); + } + + [Fact] + public async Task MaxActionFailureDoesNotMutateLocalMessageState() + { + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + var chat = await CreateDirectChatAsync(client, "max-action-failure-chat", "Action Failure"); + var messageResponse = await client.PostAsJsonAsync( + $"/api/chats/{chat.Id}/messages", + new SendMessageRequest("original guarded text")); + await AssertStatusAsync(HttpStatusCode.OK, messageResponse); + var message = await messageResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(message); + Assert.False(string.IsNullOrWhiteSpace(message!.ExternalId)); + + using var editRequest = new HttpRequestMessage(HttpMethod.Patch, $"/api/chats/{chat.Id}/messages/{message.Id}") + { + Content = JsonContent.Create(new EditMessageRequest("should not persist"), options: JsonOptions) + }; + var editResponse = await client.SendAsync(editRequest); + await AssertStatusAsync(HttpStatusCode.Conflict, editResponse); + + var afterEdit = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages", JsonOptions); + var unchanged = Assert.Single(afterEdit!, x => x.Id == message.Id); + Assert.Equal("original guarded text", unchanged.Text); + Assert.Null(unchanged.EditedAt); + + var reactionResponse = await client.PutAsJsonAsync( + $"/api/chats/{chat.Id}/messages/{message.Id}/reaction", + new SetReactionRequest("\ud83d\udc4d"), + JsonOptions); + await AssertStatusAsync(HttpStatusCode.Conflict, reactionResponse); + + var afterReaction = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages", JsonOptions); + Assert.Empty(Assert.Single(afterReaction!, x => x.Id == message.Id).Reactions); + + var deleteResponse = await client.DeleteAsync($"/api/chats/{chat.Id}/messages/{message.Id}"); + await AssertStatusAsync(HttpStatusCode.Conflict, deleteResponse); + + var afterDelete = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages", JsonOptions); + Assert.Contains(afterDelete!, x => x.Id == message.Id && x.DeletedAt is null); + } + + [Fact] + public async Task FailedMaxTextSendDoesNotCreateLocalMessage() + { + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + var chat = await CreateDirectChatAsync(client, "max-send-failure-chat", "Send Failure"); + var response = await client.PostAsJsonAsync( + $"/api/chats/{chat.Id}/messages", + new SendMessageRequest("this must not be stored")); + await AssertStatusAsync(HttpStatusCode.BadGateway, response); + + var messages = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages", JsonOptions); + Assert.NotNull(messages); + Assert.Empty(messages!); + + var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); + var updated = Assert.Single(chats!, x => x.Id == chat.Id); + Assert.Null(updated.LastMessagePreview); + Assert.Null(updated.LastMessageAt); + } + + [Fact] + public async Task ChatSearchFindsMessageTextAndAttachmentNames() + { + using var client = _factory.CreateClient(); + await LoginAsync(client); + + var created = await client.PostAsJsonAsync( + "/api/chats/direct", + new CreateDirectChatRequest("mock-search-chat", "Search Smoke")); + await AssertStatusAsync(HttpStatusCode.OK, created); + var chat = await created.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(chat); + + var textResponse = await client.PostAsJsonAsync( + $"/api/chats/{chat!.Id}/messages", + new SendMessageRequest("findable espresso marker")); + await AssertStatusAsync(HttpStatusCode.OK, textResponse); + + using var form = new MultipartFormDataContent(); + using var fileContent = new ByteArrayContent([0x25, 0x50, 0x44, 0x46]); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); + form.Add(fileContent, "file", "needle-report.pdf"); + + var upload = await client.PostAsync($"/api/chats/{chat.Id}/attachments", form); + await AssertStatusAsync(HttpStatusCode.OK, upload); + + var textSearch = await client.GetFromJsonAsync( + $"/api/chats/{chat.Id}/search?q=espresso", + JsonOptions); + Assert.NotNull(textSearch); + Assert.Contains(textSearch!, x => x.Text == "findable espresso marker"); + + var fileSearch = await client.GetFromJsonAsync( + $"/api/chats/{chat.Id}/search?q=needle", + JsonOptions); + Assert.NotNull(fileSearch); + Assert.Contains(fileSearch!, x => x.Attachments.Any(a => a.FileName == "needle-report.pdf")); + + var emptySearch = await client.GetAsync($"/api/chats/{chat.Id}/search?q="); + await AssertStatusAsync(HttpStatusCode.BadRequest, emptySearch); + } + + [Fact] + public void RemoteMediaStorageNormalizesMaxFallbackFileNames() + { + Assert.Equal( + "max-video.mp4", + AttachmentStorageService.NormalizeRemoteFileName( + "\u0412\u0430\u0448 \u0431\u0440\u0430\u0443\u0437\u0435\u0440 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u0438\u0434\u0435\u043e", + "video/mp4", + AttachmentKind.Video)); + + Assert.Equal( + "max-image.webp", + AttachmentStorageService.NormalizeRemoteFileName( + "\ud83e\udd1d", + "image/webp", + AttachmentKind.Image)); + } + + [Fact] + public void MessageTextSanitizerHidesQuestionMarkArtifacts() + { + Assert.Equal( + "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435", + MessageTextSanitizer.CleanChatPreview("[????] ??????:")); + Assert.Equal( + "\u041c\u0435\u0434\u0438\u0430", + MessageTextSanitizer.CleanChatPreview("\u0424\u043e\u0442\u043e")); + Assert.Null(MessageTextSanitizer.CleanMessageText("\u0424\u043e\u0442\u043e", hasAttachments: true)); + Assert.Equal("\u0424\u043e\u0442\u043e", MessageTextSanitizer.CleanMessageText("\u0424\u043e\u0442\u043e", hasAttachments: false)); + Assert.Null(MessageTextSanitizer.CleanMessageText("[????] ??????:", hasAttachments: true)); + Assert.Equal("hello ????", MessageTextSanitizer.CleanChatPreview("hello ????")); + } + + [Fact] + public async Task AdminStatusRequiresPairingCode() + { + using var client = _factory.CreateClient(); + + var unauthorized = await client.GetAsync("/admin/api/status"); + await AssertStatusAsync(HttpStatusCode.Unauthorized, unauthorized); + + var authorized = await client.GetAsync("/admin/api/status?pairingCode=test-pairing"); + await AssertStatusAsync(HttpStatusCode.OK, authorized); + using var json = await JsonDocument.ParseAsync(await authorized.Content.ReadAsStreamAsync()); + Assert.Equal("QMAX Bridge", json.RootElement.GetProperty("name").GetString()); + Assert.True(json.RootElement.GetProperty("maxAuthorized").GetBoolean()); + } + + [Fact] + public async Task AdminUsersCanListAndDeleteUsers() + { + using var client = _factory.CreateClient(); + await LoginAsync(client); + + var usersResponse = await client.GetAsync("/admin/api/users?pairingCode=test-pairing"); + await AssertStatusAsync(HttpStatusCode.OK, usersResponse); + using var usersJson = await JsonDocument.ParseAsync(await usersResponse.Content.ReadAsStreamAsync()); + var user = Assert.Single(usersJson.RootElement.EnumerateArray()); + var userId = user.GetProperty("id").GetGuid(); + Assert.Equal("QMAX Owner", user.GetProperty("displayName").GetString()); + Assert.NotEmpty(user.GetProperty("sessions").EnumerateArray()); + + var delete = await client.DeleteAsync($"/admin/api/users/{userId}?pairingCode=test-pairing"); + await AssertStatusAsync(HttpStatusCode.NoContent, delete); + + var afterDelete = await client.GetAsync("/admin/api/users?pairingCode=test-pairing"); + await AssertStatusAsync(HttpStatusCode.OK, afterDelete); + using var afterDeleteJson = await JsonDocument.ParseAsync(await afterDelete.Content.ReadAsStreamAsync()); + Assert.Empty(afterDeleteJson.RootElement.EnumerateArray()); + } + + public void Dispose() + { + _factory.Dispose(); + foreach (var suffix in new[] { "", "-shm", "-wal" }) + { + var path = _databasePath + suffix; + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + private static async Task LoginAsync(HttpClient client) + { + var authResponse = await client.PostAsJsonAsync( + "/api/auth/device/login", + new DeviceLoginRequest("test-pairing", "xunit")); + + await AssertStatusAsync(HttpStatusCode.OK, authResponse); + var auth = await authResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(auth); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth!.AccessToken); + } + + private static async Task CreateDirectChatAsync(HttpClient client, string externalId, string title) + { + var response = await client.PostAsJsonAsync( + "/api/chats/direct", + new CreateDirectChatRequest(externalId, title)); + await AssertStatusAsync(HttpStatusCode.OK, response); + var chat = await response.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(chat); + return chat!; + } + + private static async Task AssertStatusAsync(HttpStatusCode expected, HttpResponseMessage response) + { + if (response.StatusCode != expected) + { + var body = await response.Content.ReadAsStringAsync(); + throw new Xunit.Sdk.XunitException($"Expected {expected}, got {response.StatusCode}. Body: {body}"); + } + } + + private sealed class FailingActionMaxBridgeClient : IMaxBridgeClient + { + public Task GetStatusAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow)); + } + + public Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) + { + return GetStatusAsync(cancellationToken); + } + + public Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) + { + return GetStatusAsync(cancellationToken); + } + + public Task GetSnapshotAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow)); + } + + public Task> FetchUpdatesAsync(CancellationToken cancellationToken) + { + return Task.FromResult>(Array.Empty()); + } + + public Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); + } + + public Task SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null)); + } + + public Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) + { + return Fail(); + } + + public Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) + { + return Fail(); + } + + public Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return Fail(); + } + + public Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return Fail(); + } + + public Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + private static Task Fail() + { + return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure.")); + } + } + + private sealed class FailingSendMaxBridgeClient : IMaxBridgeClient + { + public Task GetStatusAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow)); + } + + public Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) + { + return GetStatusAsync(cancellationToken); + } + + public Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) + { + return GetStatusAsync(cancellationToken); + } + + public Task GetSnapshotAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow)); + } + + public Task> FetchUpdatesAsync(CancellationToken cancellationToken) + { + return Task.FromResult>(Array.Empty()); + } + + public Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX send failure.")); + } + + public Task SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX attachment failure.")); + } + + public Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure.")); + } + + public Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure.")); + } + + public Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure.")); + } + + public Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure.")); + } + + public Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + } + + private sealed class LateAttachmentMaxBridgeClient : IMaxBridgeClient + { + public const string ChatExternalId = "mock-late-attachment-chat"; + public static readonly byte[] MediaBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x44, 0x41, 0x54, 0x41]; + + private int _fetchCount; + + public Task GetStatusAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow)); + } + + public Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) + { + return GetStatusAsync(cancellationToken); + } + + public Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) + { + return GetStatusAsync(cancellationToken); + } + + public Task GetSnapshotAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow)); + } + + public Task> FetchUpdatesAsync(CancellationToken cancellationToken) + { + _fetchCount++; + return Task.FromResult>([BuildUpdate(includeAttachment: _fetchCount > 1)]); + } + + public Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); + } + + public Task SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null)); + } + + public Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) + { + return Task.FromResult( + new MaxMediaDownload(new MemoryStream(MediaBytes), "image/png", MediaBytes.Length)); + } + + private static MaxChatUpdate BuildUpdate(bool includeAttachment) + { + IReadOnlyList attachments = includeAttachment + ? + [ + new MaxAttachmentUpdate( + "late-media-1", + "\u0424\u043e\u0442\u043e", + "image/png", + MediaBytes.Length, + "https://max.test/media/late-media-1.png", + nameof(AttachmentKind.Image), + 0) + ] + : []; + + return new MaxChatUpdate( + ChatExternalId, + "Late Attachment", + null, + DateTimeOffset.Parse("2026-07-02T12:00:00+00:00"), + [ + new MaxMessageUpdate( + "late-message-1", + "sender-1", + "MAX Sender", + false, + "\u0424\u043e\u0442\u043e", + DateTimeOffset.Parse("2026-07-02T12:00:00+00:00"), + attachments) + ]); + } + } + + private sealed class DeferredRemoteMediaMaxBridgeClient : IMaxBridgeClient + { + public const string ChatExternalId = "mock-deferred-remote-media-chat"; + public static readonly byte[] MediaBytes = [0x89, 0x50, 0x4E, 0x47, 0x44, 0x45, 0x46, 0x45, 0x52]; + + public bool AllowMediaDownload { get; set; } + + public Task GetStatusAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow)); + } + + public Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) + { + return GetStatusAsync(cancellationToken); + } + + public Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) + { + return GetStatusAsync(cancellationToken); + } + + public Task GetSnapshotAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow)); + } + + public Task> FetchUpdatesAsync(CancellationToken cancellationToken) + { + return Task.FromResult>( + [ + new MaxChatUpdate( + ChatExternalId, + "Deferred Media", + null, + DateTimeOffset.Parse("2026-07-02T12:05:00+00:00"), + [ + new MaxMessageUpdate( + "deferred-message-1", + "sender-1", + "MAX Sender", + false, + "\u0424\u043e\u0442\u043e", + DateTimeOffset.Parse("2026-07-02T12:05:00+00:00"), + [ + new MaxAttachmentUpdate( + "deferred-media-1", + "\u0424\u043e\u0442\u043e", + "image/png", + null, + "https://max.test/media/deferred-media-1.png", + nameof(AttachmentKind.Image), + 0) + ]) + ]) + ]); + } + + public Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); + } + + public Task SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null)); + } + + public Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) + { + if (!AllowMediaDownload) + { + return Task.FromResult(null); + } + + return Task.FromResult( + new MaxMediaDownload(new MemoryStream(MediaBytes), "image/png", MediaBytes.Length)); + } + } +} diff --git a/tests/QMax.Tests/QMax.Tests.csproj b/tests/QMax.Tests/QMax.Tests.csproj new file mode 100644 index 0000000..1d18ceb --- /dev/null +++ b/tests/QMax.Tests/QMax.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/worker/.dockerignore b/worker/.dockerignore new file mode 100644 index 0000000..69cfdb3 --- /dev/null +++ b/worker/.dockerignore @@ -0,0 +1,4 @@ +node_modules +npm-debug.log +.env +data diff --git a/worker/Dockerfile b/worker/Dockerfile new file mode 100644 index 0000000..320a364 --- /dev/null +++ b/worker/Dockerfile @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..149f7bd --- /dev/null +++ b/worker/package-lock.json @@ -0,0 +1,933 @@ +{ + "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 new file mode 100644 index 0000000..68f8c4b --- /dev/null +++ b/worker/package.json @@ -0,0 +1,15 @@ +{ + "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 new file mode 100644 index 0000000..fea1f33 --- /dev/null +++ b/worker/src/server.js @@ -0,0 +1,3072 @@ +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:"; + +let context; +let page; +let lastError = null; +let pageOperation = Promise.resolve(); +const networkLog = []; + +const app = express(); +app.use(cors()); +app.use(express.json({ limit: "10mb" })); + +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 p = await ensurePage(); + return await inspectDom(p); + })); + } 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("/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(); + return await fetchMediaThroughMaxSession(p, mediaUrl); + }); + + 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(); + const currentStatus = await status(); + if (!currentStatus.isAuthorized) { + return []; + } + + await clearSidebarSearch(p); + return await extractUpdates(p); + }); + 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 result = await withPageLock(async () => { + const p = await ensurePage(); + const currentStatus = await status(); + if (!currentStatus.isAuthorized) { + return null; + } + + if (externalChatId) { + const opened = await ensureDomChatOpen(p, externalChatId, 1000); + if (!opened) { + return null; + } + } + + return await extractOpenChatHistory(p, externalChatId); + }); + + 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/presence", async (req, res) => { + try { + const externalChatId = String(req.body?.externalChatId || "").trim(); + const result = await withPageLock(async () => { + const p = await ensurePage(); + const currentStatus = await status(); + if (!currentStatus.isAuthorized) { + return { isTyping: false, statusText: null, updatedAt: new Date().toISOString() }; + } + + if (externalChatId) { + await ensureDomChatOpen(p, externalChatId, 600); + } + + return await extractOpenChatPresence(p); + }); + + 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 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(); + + if (externalChatId) { + const opened = await ensureDomChatOpen(p, externalChatId, 600); + if (!opened) { + return { success: false, externalMessageId: null, error: "MAX chat was not found or did not open." }; + } + } + + const externalMessageId = await sendTextAndConfirm(p, externalChatId, text); + if (!externalMessageId) { + return { success: false, externalMessageId: null, error: "MAX did not confirm the outgoing text message." }; + } + return { success: true, externalMessageId, error: null }; + }); + 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 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(); + if (externalChatId) { + const opened = await ensureDomChatOpen(p, externalChatId, 600); + if (!opened) { + return { success: false, externalMessageId: null, error: "MAX chat was not found or did not open." }; + } + } + + const uploaded = await uploadAttachmentFile(p, path, caption); + if (!uploaded) { + return { success: false, externalMessageId: null, error: "MAX attachment upload controls were not found or rejected the file." }; + } + + return { success: true, externalMessageId: `web-file-${Date.now()}`, error: null }; + }); + + 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(); + return await editMessageInMax(p, externalChatId, externalMessageId, currentText, text); + }); + 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(); + return await deleteMessageInMax(p, externalChatId, externalMessageId, currentText); + }); + 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(); + return await setMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji); + }); + 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(); + return await clearMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji); + }); + 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() { + if (page) return page; + 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"] + }); + page = context.pages()[0] || await context.newPage(); + attachNetworkCapture(page); + if (!page.url() || page.url() === "about:blank") { + await page.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); + } + return page; +} + +async function withPageLock(operation) { + const previousOperation = pageOperation; + let release; + pageOperation = new Promise((resolve) => { + release = resolve; + }); + + await previousOperation.catch(() => {}); + try { + return await operation(); + } finally { + release(); + } +} + +async function status(overrideStatus = null) { + try { + const p = 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, + updatedAt: new Date().toISOString() + }; + } catch (error) { + return errorStatus(error); + } +} + +function errorStatus(error) { + lastError = String(error?.message || error); + return { + mode: "Worker", + isAuthorized: false, + status: "WorkerError", + url: page?.url?.() || null, + title: null, + lastError, + updatedAt: new Date().toISOString() + }; +} + +function isAllowedMediaUrl(value) { + const raw = String(value || "").trim(); + if (!raw) return false; + if (isDomDownloadUrl(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 parseDomDownloadUrl(value) { + const raw = String(value || ""); + if (!isDomDownloadUrl(raw)) { + return null; + } + + try { + return JSON.parse(decodeURIComponent(raw.slice(domDownloadPrefix.length))); + } catch { + return null; + } +} + +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)) { + return await downloadDomAttachmentThroughClick(p, parseDomDownloadUrl(mediaUrl)); + } + + 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 + }; +} + +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 elements = Array.from(document.querySelectorAll("a,button,input,textarea,[role],[data-testid],[class]")) + .filter(isVisible) + .map((element) => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName.toLowerCase(), + 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"), + 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) => element.text || element.ariaLabel || element.testId || element.role) + .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(() => { + 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 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 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 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 = []; + for (const element of Array.from(document.querySelectorAll(chatSelector))) { + if (!isVisible(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 key = `${title}|${text}`; + if (seen.has(key)) continue; + seen.add(key); + chats.push({ + title, + text, + preview: extractPreview(element, title, lines), + timeText: extractTimeText(element, lines), + avatarUrl: extractAvatarUrl(element), + href: element.getAttribute("href") || null, + rect: { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height) + } + }); + if (chats.length >= 80) break; + } + + return { + url: location.href, + title: document.title, + chats + }; + }); + + const now = new Date().toISOString(); + return raw.chats.map((chat, index) => { + const externalId = makeDomChatExternalId(chat.title, chat.text, chat.href, index); + 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, + updatedAt: now, + lastMessagePreview: messageText || null, + lastMessageIsOutgoing: outgoing, + lastMessageAt: messageText ? now : null, + lastMessageTimeText: chat.timeText || null, + messages: [] + }; + }); +} + +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 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 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 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 nodes = Array.from(bubble.querySelectorAll("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]")); + 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 mediaTag = tagName === "source" ? String(node.parentElement?.tagName || "").toLowerCase() : tagName; + const kind = kindFromMedia(mediaTag, url, label); + const semantic = [ + tagName, + node.getAttribute?.("role"), + node.getAttribute?.("aria-label"), + node.getAttribute?.("title"), + node.className?.baseVal || node.className, + label + ].filter(Boolean).join(" "); + 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 wrappers = Array.from(opened.querySelectorAll("[class*='messageWrapper' i]")) + .filter(isVisible); + 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 metaText = cleanText(wrapper.querySelector("[class*='meta' i]")?.innerText, 80); + const timeText = metaText.match(/\b\d{1,2}:\d{2}\b/)?.[0] || ""; + 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), + 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, + 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) => ({ + ...attachment, + externalId: `dommedia:${stableHash(externalId, messageExternalId, attachment.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") + ].join(" ").toLowerCase(); + return rect.left < window.innerWidth * 0.42 && + rect.top < window.innerHeight * 0.32 && + /search|\u043f\u043e\u0438\u0441\u043a/.test(text); + }); + + 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.keyboard.press("Escape").catch(() => {}); + await p.waitForTimeout(150); +} + +async function openDomChatByExternalId(p, externalChatId) { + const title = decodeDomChatTitle(externalChatId) || externalChatId; + if (!title) { + return false; + } + + await clearSidebarSearch(p); + + return await p.evaluate((requestedTitle) => { + const cleanText = (value) => String(value || "") + .replace(/\u00a0/g, " ") + .replace(/\s+/g, " ") + .trim(); + const normalize = (value) => cleanText(value).toLowerCase(); + const wanted = normalize(requestedTitle); + 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 currentTitles = [selectedTitle, headerTitle].filter(Boolean); + if (currentTitles.some((current) => current === wanted || current.includes(wanted) || wanted.includes(current))) { + return true; + } + + const buttons = Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i]")); + const match = buttons.find((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); + return title === wanted || + title.includes(wanted) || + wanted.includes(title) || + text.startsWith(wanted); + }); + + if (!match) { + return false; + } + + match.scrollIntoView({ block: "center", inline: "nearest" }); + match.click(); + return true; + }, title).catch(() => false); +} + +async function ensureDomChatOpen(p, externalChatId, waitMs = 600) { + const title = decodeDomChatTitle(externalChatId) || externalChatId; + if (!title) { + return true; + } + + const opened = await openDomChatByExternalId(p, externalChatId); + if (!opened) { + return false; + } + + await p.waitForTimeout(waitMs); + const verified = await p.evaluate((requestedTitle) => { + const cleanText = (value) => String(value || "") + .replace(/\u00a0/g, " ") + .replace(/\s+/g, " ") + .trim(); + const normalize = (value) => cleanText(value).toLowerCase(); + const wanted = normalize(requestedTitle); + const opened = document.querySelector("[class*='openedChat' i]") || + document.querySelector("main") || + document.body; + 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 current = normalize(selectedTitle || headerTitle); + if (!current) { + return null; + } + + return current === wanted || current.includes(wanted) || wanted.includes(current); + }, title).catch(() => null); + + return verified === null ? opened : verified; +} + +async function sendTextAndConfirm(p, externalChatId, text) { + const wanted = cleanComparableText(text, 1000); + if (!wanted) { + return null; + } + + const initialMatches = await countOutgoingTextMatches(p, externalChatId, text); + const attempts = [ + { name: "send button", action: () => clickSendButton(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; } }, + { name: "composer fallback button", action: () => clickComposerRightmostButton(p) } + ]; + + for (const attempt of attempts) { + if (!(await fillComposerText(p, text, true))) { + return null; + } + + const composerText = await readComposerText(p); + if (composerText !== null && !messageTextMatches(composerText, wanted)) { + continue; + } + + const triggered = await attempt.action().catch(() => false); + if (!triggered) { + continue; + } + + const externalId = await findSentOutgoingExternalId(p, externalChatId, text, initialMatches, 10000); + if (externalId) { + return externalId; + } + + const currentComposerText = await readComposerText(p); + if (currentComposerText !== null && currentComposerText.length === 0) { + return null; + } + } + + return null; +} + +async function fillComposerText(p, text, clearFirst) { + 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 currentText = await readComposerText(p); + if (currentText !== null && messageTextMatches(currentText, 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 currentText = await readComposerText(p); + if (currentText !== null && messageTextMatches(currentText, 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 { + 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); + return candidates[0] || { + 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) { + const text = await readComposerText(p); + if (text !== null && text.length === 0) { + return true; + } + + await p.waitForTimeout(250); + } + + return false; +} + +async function readComposerText(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) => { + 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 : ""; + return cleanText(value || composer.innerText || composer.textContent); + }).catch(() => null); +} + +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 getOutgoingTextMatches(p, externalChatId, text) { + const wanted = cleanComparableText(text, 1000); + if (!wanted) { + return []; + } + + const history = await extractOpenChatHistory(p, externalChatId).catch(() => null); + return (history?.messages || []).filter((message) => + message.isOutgoing && 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, timeoutMs = 15000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await p.waitForTimeout(500); + const matches = await getOutgoingTextMatches(p, externalChatId, text); + if (matches.length > previousMatchCount) { + const latest = matches[matches.length - 1]; + if (latest?.externalId) { + return latest.externalId; + } + } + } + + return null; +} + +async function uploadAttachmentFile(p, path, caption) { + try { + const fileStat = await fs.stat(path); + if (!fileStat.isFile()) { + return false; + } + } catch { + return false; + } + + let attached = false; + const uploadButton = p.locator("[class*='composer' i] button").first(); + if ((await uploadButton.count()) > 0) { + try { + const fileChooserPromise = p.waitForEvent("filechooser", { timeout: 5000 }); + await uploadButton.click({ timeout: 3000 }); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path); + attached = true; + } catch { + attached = false; + } + } + + 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) { + return false; + } + + await p.waitForTimeout(1200); + if (caption.trim()) { + await fillComposerText(p, caption.trim(), false); + await p.waitForTimeout(300); + } + + if (!(await clickSendButton(p))) { + return false; + } + await p.waitForTimeout(1200); + return caption.trim() ? await waitForComposerToClear(p, 3000) : true; +} + +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 match = buttons.find((button) => { + const text = [ + button.getAttribute("aria-label"), + button.getAttribute("title"), + button.getAttribute("data-testid"), + button.className, + button.innerText, + button.textContent + ].join(" ").trim().toLowerCase(); + return /send|submit|\u043e\u0442\u043f\u0440\u0430\u0432/.test(text); + }); + + 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 buttons = Array.from(document.querySelectorAll("[class*='composer' i] button, [class*='composer' i] [role='button']")) + .filter((button) => !button.disabled && isVisible(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 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 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 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 candidates = Array.from(document.querySelectorAll("button,[role='button'],[role='menuitem'],span,div")) + .filter((element) => element instanceof HTMLElement && isVisible(element)); + const match = candidates.find((element) => { + const text = textOf(element); + return text && normalizedLabels.some((label) => text === label || text.includes(label)); + }); + if (!match) { + return false; + } + + const clickable = match.closest("button,[role='button'],[role='menuitem']") || match; + clickable.click(); + return true; + }, labels).catch(() => false); +} + +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 makeDomChatExternalId(title, text, href, index) { + const encodedTitle = Buffer.from(String(title || ""), "utf8") + .toString("base64url") + .slice(0, 120); + return `dom:${stableHash("chat", title, href)}:${encodedTitle}`; +} + +function decodeDomChatTitle(externalChatId) { + const parts = String(externalChatId || "").split(":"); + if (parts.length < 3 || parts[0] !== "dom") { + return null; + } + + try { + return Buffer.from(parts[2], "base64url").toString("utf8"); + } catch { + return 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"); +} diff --git a/тз.md b/тз.md new file mode 100644 index 0000000..233f91c --- /dev/null +++ b/тз.md @@ -0,0 +1,882 @@ +# ТЗ: мессенджер в стиле Икар + +## Назначение документа + +Этот документ является техническим заданием для нейросети или команды разработки, которой нужно создать полноценный мессенджер, функционально и визуально близкий к проекту Икар. + +Цель: получить современный мобильный мессенджер с серверной частью, Android-клиентом, real-time сообщениями, медиа-вложениями, звонками, сторис, ботами, каналами, группами, push-уведомлениями, настройками приватности, админкой и механизмом обновления приложения. + +Документ нужно читать как product + engineering brief. Если какие-то детали не указаны явно, нужно выбирать решения, близкие к Telegram-подобному пользовательскому опыту: быстрый чат, аккуратные пузырьки, плотная мобильная навигация, удобные вложения, плавная работа с медиа и надежная доставка сообщений. + +## Рабочее название + +Икар. + +## Ключевая идея + +Икар - это самостоятельный мессенджер с мобильным Android-клиентом и собственным сервером. Он должен поддерживать личные чаты, группы, каналы, медиа, звонки, сторис, ботов и обновления клиента через собственный каталог релизов. + +Пользователь должен воспринимать приложение как быстрый, легкий и приватный мессенджер: + +- открыл приложение; +- увидел список чатов; +- вошел в чат; +- написал текст; +- отправил фото, видео, файл или голосовое; +- получил push; +- открыл фото на весь экран; +- позвонил; +- настроил приватность; +- обновил приложение через встроенный механизм. + +## Платформы + +Минимальный обязательный состав: + +- Backend: ASP.NET Core Web API. +- Realtime: SignalR или аналогичный WebSocket transport. +- Android client: Kotlin + Jetpack Compose. +- Storage: локальное файловое хранилище на сервере для вложений и аватаров. +- Database: реляционная БД через ORM, желательно Entity Framework Core или аналог. +- Push: Firebase Cloud Messaging. +- Admin UI: веб-админка, доступная через сервер. +- Distribution: публикация Android APK через каталог обновлений Argus-like. + +Дополнительно допустимо: + +- Docker-окружение для сервера. +- Отдельный shared-модуль с контрактами API. +- Локальный кэш сообщений и вложений на Android. + +## Главные пользовательские сценарии + +### Регистрация и вход + +Пользователь должен иметь возможность: + +- зарегистрироваться по номеру телефона; +- войти по номеру телефона через одноразовый код; +- войти или подтвердить аккаунт через email-код; +- получить JWT/access token; +- восстановить сессию после перезапуска приложения; +- выйти из аккаунта; +- видеть список активных устройств; +- завершать сессии на других устройствах. + +В dev/smoke окружении можно использовать тестовые коды, но в production нужно иметь реальную отправку SMS/email или подключаемые провайдеры. + +### Профиль пользователя + +Пользователь должен иметь: + +- display name; +- username или публичный идентификатор, если нужен; +- avatar; +- about/status; +- настройки приватности; +- настройки уведомлений; +- настройки интерфейса чатов. + +Нужно поддерживать загрузку аватара с Android-клиента, хранение на сервере и отдачу с авторизацией. + +### Список чатов + +Главный экран после авторизации - список чатов. + +В списке чатов должны отображаться: + +- аватар чата или пользователя; +- название; +- последний текст или превью вложения; +- дата/время последнего сообщения; +- бейдж непрочитанных; +- индикатор pinned/muted/archived, если реализовано; +- превью фото для последнего медиа-сообщения; +- статус онлайн для личных чатов. + +Список должен обновляться в real-time при новых сообщениях. + +### Личные чаты + +Пользователь должен иметь возможность: + +- создать личный чат с другим пользователем; +- отправлять текст; +- отправлять вложения; +- отвечать на сообщение; +- пересылать сообщение; +- копировать текст; +- удалять сообщение; +- редактировать свое сообщение; +- ставить реакции; +- искать по сообщениям; +- видеть статус доставки. + +Статусы доставки: + +- sending; +- sent; +- delivered; +- read; +- failed. + +### Группы + +Групповой чат должен поддерживать: + +- создание группы; +- добавление участников; +- удаление участников; +- роли участников; +- права отправки сообщений; +- название и аватар группы; +- список участников; +- уведомления; +- поиск; +- media/materials view. + +Минимальные роли: + +- owner; +- admin; +- member. + +### Каналы + +Каналы должны отличаться от групп: + +- подписчики читают публикации; +- писать могут только владелец/админы; +- у публикаций могут быть просмотры; +- у публикаций могут быть комментарии через discussion group; +- канал может иметь invite link; +- канал может быть публичным или приватным. + +Для каналов нужны: + +- создание канала; +- управление участниками/подписчиками; +- публикация постов; +- пересылка постов; +- счетчик просмотров; +- переход в обсуждение. + +### Сообщения + +Сообщение должно поддерживать: + +- id; +- chatId; +- sender; +- text; +- sentAt; +- editedAt; +- deletedAt; +- replyTo; +- forwardedFrom; +- attachments; +- reactions; +- deliveryState; +- hasProtectedContent; +- viewCount для каналов; +- discussionMessageId для каналов; +- mediaAlbumId для альбомов. + +Текст должен поддерживать: + +- переносы строк; +- ссылки; +- inline preview для ссылок на изображения; +- spoiler-style разметку, если реализуется; +- большие emoji-сообщения, если сообщение состоит только из 1-3 emoji. + +### Вложения + +Нужно поддерживать вложения: + +- изображения: jpg, jpeg, png, webp, gif, bmp, heic, heif; +- видео: mp4, mov, webm, mkv и похожие; +- аудио; +- голосовые сообщения; +- документы/файлы; +- встроенные GIF; +- стикеры. + +Ограничение размера по умолчанию: 25 MB на файл, если не задано другое. + +У вложения должны быть: + +- id; +- fileName; +- contentType; +- fileSizeBytes; +- downloadPath; +- kind; +- sortOrder. + +Типы kind: + +- File; +- VoiceNote; +- Image; +- Video; +- Sticker; +- Gif. + +Если backend не хранит kind явно для всех типов, клиент должен уметь определять тип по contentType и расширению файла. + +### Отправка фото + +Отправка фото должна работать надежно. + +Требования: + +- пользователь выбирает фото через системный picker; +- фото копируется во временный локальный файл приложения; +- приложение отображает preview перед отправкой; +- при отправке файл передается на сервер multipart/form-data; +- сервер сохраняет оригинал; +- сервер возвращает сообщение с attachment metadata; +- получатели видят фото в чате без перезапуска; +- при нажатии фото открывается полноэкранный просмотр. + +Важное визуальное требование: + +- одиночное фото в чате должно отображаться как media-сообщение без обычного белого текстового пузыря; +- не должно быть широких белых полей вокруг фото; +- preview в чате должно сохранять корректное соотношение сторон; +- если изображение очень широкое или очень высокое, нужно ограничивать максимальную высоту, но не показывать белую пустую область как часть сообщения; +- фон загрузки не должен выглядеть как белый прямоугольник; +- если файл еще не докачан, нельзя показывать частично скачанный JPEG как готовое изображение. + +Техническое требование для кэша: + +- вложения скачивать сначала во временный файл, например `filename.part`; +- после полной загрузки и проверки размера атомарно переносить во финальный cache-файл; +- UI должен считать cache-файл готовым только если он существует, не пустой и, если известен ожидаемый размер, совпадает с `fileSizeBytes`; +- при несовпадении размера старый cache-файл нужно игнорировать или перекачивать; +- image loader не должен кэшировать частично скачанные изображения. + +### Фотоальбомы + +Если пользователь отправляет несколько изображений/видео одновременно: + +- файлы должны отправляться одним album-сообщением, если все вложения являются media; +- у всех вложений должен быть общий mediaAlbumId; +- в чате нужно показывать collage/grid; +- порядок должен соответствовать выбору пользователя; +- при нажатии открывается просмотрщик с навигацией по фото альбома. + +### Просмотр фото + +Фото-просмотрщик должен: + +- открываться на весь экран; +- иметь черный фон; +- показывать фото целиком через ContentScale/Fit; +- поддерживать pinch-to-zoom; +- поддерживать drag при увеличении; +- иметь кнопку закрытия/назад; +- иметь имя отправителя и время; +- поддерживать переход к предыдущему/следующему фото в контексте чата или альбома; +- иметь меню: сохранить, поделиться, открыть внешне, если разрешено. + +### Видео + +Видео-сообщение должно: + +- отображаться карточкой с preview/play; +- открываться в full-screen player; +- проигрываться через Media3/ExoPlayer или аналог; +- поддерживать авторизованные URL; +- иметь play/pause; +- иметь закрытие назад. + +### Голосовые сообщения + +Нужно поддерживать: + +- запись голосового; +- таймер записи; +- отмену записи; +- отправку как attachment kind VoiceNote; +- воспроизведение в чате; +- progress bar; +- stop/play; +- паузу внешней музыки при записи или воспроизведении, если настройка включена. + +### Реакции + +Сообщения должны поддерживать реакции: + +- пользователь может поставить emoji-реакцию; +- повторное нажатие может убрать или сменить реакцию; +- счетчики реакций обновляются real-time; +- реакции отображаются компактно под сообщением. + +### Ответы, пересылка, редактирование, удаление + +Нужно реализовать: + +- reply preview над composer; +- reply preview внутри сообщения; +- переход к исходному сообщению по tap на reply; +- edit mode для своих сообщений; +- delete для себя или для всех, если разрешено; +- forward flow с выбором чата; +- protected content: если сообщение защищено, запретить копирование/пересылку/сохранение. + +### Поиск + +Нужен глобальный поиск: + +- по пользователям; +- по чатам; +- по сообщениям; +- по вложениям; +- по медиа; +- по файлам. + +Нужен поиск внутри чата: + +- строка поиска в header; +- подсветка результатов; +- навигация вверх/вниз по результатам; +- загрузка старых сообщений при необходимости. + + + +### Уведомления + +Push-уведомления должны приходить для: + +- новых сообщений; +- входящих звонков; +- важных системных событий. + +Требования: + +- Firebase token registration; +- настройка звука; +- mute per chat; +- глобальное включение/выключение preview; +- не показывать уведомление, если пользователь уже находится в активном чате; +- действия в уведомлении: открыть чат, принять/отклонить звонок. + +### Приватность и безопасность + +Нужно реализовать: + +- JWT auth; +- авторизацию на всех API; +- проверку членства в чате перед доступом к сообщениям и вложениям; +- запрет скачивания вложений неучастниками; +- privacy settings; +- protected content; +- secure window option на Android; +- rate limiting на API; +- удаление пользователя админом; +- удаление чата и файлов; +- server-side validation для всех upload. + +Настройки приватности: + +- кто видит номер телефона; +- кто видит avatar; +- кто видит online; +- кто может звонить; +- кто может добавлять в группы; +- показывать или скрывать message preview. + +### Настройки приложения + +Нужны настройки: + +- язык/локаль; +- тема/цвета, если реализуется; +- размер текста сообщений; +- радиус пузырьков; +- обои чата; +- layout списка чатов; +- автоскачивание фото; +- автоскачивание видео по Wi-Fi; +- автоскачивание файлов по Wi-Fi; +- сохранять медиа в галерею; +- защищенный экран; +- push settings; +- настройки звонков. + +### Кэш и offline + +Android-клиент должен: + +- кэшировать последние сообщения; +- показывать кэш при старте; +- хранить scroll position; +- хранить draft text; +- кэшировать скачанные вложения; +- иметь политику очистки кэша; +- поддерживать очередь upload/download через WorkManager; +- показывать прогресс больших передач; +- повторять неудачные передачи. + +### Архитектура Android-клиента + +Рекомендуемый стек: + +- Kotlin; +- Jetpack Compose; +- Material 3; +- Navigation Compose; +- ViewModel; +- StateFlow; +- Room для локального кэша; +- DataStore для настроек; +- OkHttp для API; +- Kotlin serialization; +- SignalR client для realtime; +- Coil для изображений; +- Media3 для видео; +- WorkManager для фоновых upload/download; +- Firebase Messaging. + +Структура клиента: + +- `app` - dependency container, app bootstrap; +- `core/network` - ApiClient, ServerConfig; +- `core/model` - DTO/model classes; +- `core/realtime` - realtime service; +- `core/local` - Room entities/DAO/cache; +- `core/settings` - DataStore settings; +- `core/push` - Firebase/push; +- `feature/login`; +- `feature/chats`; +- `feature/chat`; +- `feature/profile`; +- `feature/settings`; +- `feature/stories`; +- `feature/call`; +- `feature/bots`; +- `feature/search`; +- `feature/forward`; +- `feature/chatinfo`; +- `feature/transfer`. + +### Архитектура backend + +Рекомендуемый стек: + +- ASP.NET Core; +- Controllers для REST API; +- SignalR Hub для realtime; +- EF Core; +- JWT Bearer auth; +- SQLite/PostgreSQL; +- hosted services для фоновых задач; +- local file storage для uploads; +- Dockerfile. + +Структура backend: + +- `Controllers/AuthController`; +- `Controllers/UsersController`; +- `Controllers/ChatsController`; +- `Controllers/StoriesController`; +- `Controllers/BotsController`; +- `Controllers/BotApiController`; +- `Controllers/PushController`; +- `Controllers/AppUpdatesController`; +- `Controllers/AdminController`; +- `Infrastructure/Hubs/MessengerHub`; +- `Infrastructure/Storage/AttachmentStorageService`; +- `Infrastructure/Storage/AvatarStorageService`; +- `Infrastructure/Auth/TokenService`; +- `Infrastructure/Push`; +- `Infrastructure/Calls`; +- `Infrastructure/Bots`; +- `Infrastructure/Admin`; +- `Data/IkarDbContext`; +- `Data/Entities`. + +### Основные backend entities + +Нужно спроектировать сущности: + +- User; +- UserSession; +- Chat; +- ChatMember; +- Message; +- MessageAttachment; +- MessageReaction; +- MessageReadReceipt; +- ChatInviteLink; +- ChatTopic; +- Story; +- StoryAttachment; +- Bot; +- BotCommand; +- BotUpdate; +- PushDevice; +- CallSession; +- Admin/moderation logs. + +### REST API + +Минимальные API группы: + +#### Auth + +- `POST /api/auth/phone/request-code`; +- `POST /api/auth/phone/verify`; +- `POST /api/auth/email/request-code`; +- `POST /api/auth/email/verify`; +- `POST /api/auth/refresh`; +- `POST /api/auth/logout`; +- `GET /api/auth/sessions`; +- `DELETE /api/auth/sessions/{id}`. + +#### Users + +- `GET /api/users/me`; +- `PUT /api/users/me`; +- `POST /api/users/me/avatar`; +- `GET /api/users/{id}`; +- `GET /api/users/search?q=...`; +- `GET /api/users/{id}/avatar`. + +#### Chats + +- `GET /api/chats`; +- `POST /api/chats/direct`; +- `POST /api/chats/groups`; +- `POST /api/chats/channels`; +- `GET /api/chats/{chatId}`; +- `PUT /api/chats/{chatId}`; +- `DELETE /api/chats/{chatId}`; +- `GET /api/chats/{chatId}/messages`; +- `POST /api/chats/{chatId}/messages`; +- `PUT /api/chats/{chatId}/messages/{messageId}`; +- `DELETE /api/chats/{chatId}/messages/{messageId}`; +- `POST /api/chats/{chatId}/messages/{messageId}/reactions`; +- `POST /api/chats/{chatId}/attachments`; +- `POST /api/chats/{chatId}/attachments/album`; +- `GET /api/chats/{chatId}/attachments/{attachmentId}/content`; +- `POST /api/chats/{chatId}/members`; +- `DELETE /api/chats/{chatId}/members/{userId}`; +- `GET /api/chats/{chatId}/search`. + + + +#### Push + +- `POST /api/push/devices`; +- `DELETE /api/push/devices/{id}`; +- `PUT /api/push/settings`. + + + +#### Admin + +- `GET /admin/api/status`; +- `GET /admin/api/users`; +- `GET /admin/api/chats`; +- `GET /admin/api/messages`; +- `GET /admin/api/attachments`; +- `GET /admin/api/attachments/{id}/content`; +- `DELETE /admin/api/attachments/{id}`; +- `DELETE /admin/api/users/{id}`; +- `DELETE /admin/api/chats/{id}`. + +### Realtime hub + +Realtime должен поддерживать: + +- подключение авторизованного пользователя; +- join/leave chat groups; +- отправку нового сообщения всем участникам; +- обновление сообщения; +- удаление сообщения; +- reaction update; +- typing indicator; +- read receipts; +- online/presence; +- call signaling; +- story changed notification; +- chat list invalidation. + +Пример событий: + +- `MessageCreated`; +- `MessageUpdated`; +- `MessageDeleted`; +- `ReactionUpdated`; +- `TypingChanged`; +- `ChatUpdated`; +- `UserPresenceChanged`; +- `IncomingCall`; +- `CallAccepted`; +- `CallDeclined`; +- `CallEnded`; +- `WebRtcOffer`; +- `WebRtcAnswer`; +- `IceCandidate`; +- `StoriesChanged`. + +### UI/UX стиль + +Общее направление: + +- Telegram-like; +- чистый светлый интерфейс; +- быстрые списки; +- минимум декоративности; +- акцентный голубой цвет; +- аккуратные rounded shapes; +- сообщения компактные; +- медиа без лишних рамок; +- настройки плотные и понятные; +- нижняя composer-панель всегда удобная. + +Цвета можно использовать примерно такие: + +- accent blue: `#3390EC`; +- text dark: `#17212B`; +- muted text: `#6C7883`; +- incoming bubble: `#FFFFFF`; +- outgoing bubble: `#E7F7C8`; +- app background: `#F3F7FA`; +- media placeholder: лучше прозрачный или нейтральный, но не белый прямоугольник вокруг фото. + +### Экран чата + +Экран чата должен включать: + +- header с back, avatar, title, subtitle/online, menu; +- список сообщений; +- date separators; +- pinned message banner; +- search mode; +- selection mode; +- reply/edit panel; +- composer; +- attach button; +- emoji/GIF/sticker picker; +- voice record button; +- send button; +- scroll-to-bottom button; +- active transfer cards; +- call banner. + +Поведение: + +- новые сообщения добавляются снизу; +- автоскролл вниз, если пользователь уже внизу; +- если пользователь выше по истории, показывать кнопку вниз; +- свайп сообщения влево/вправо для reply; +- long press для selection/context actions; +- double tap для reaction, если реализуется. + +### Composer + +Composer должен поддерживать: + +- многострочный ввод; +- attach picker; +- отправку текста; +- отправку вложений; +- preview выбранного файла; +- preview альбома; +- remove attachment; +- reply state; +- edit state; +- emoji/sticker/GIF panel; +- voice recording. + +### Встроенный image loader + +Для авторизованных картинок: + +- использовать access token в Authorization header; +- иметь disk/memory cache key, зависящий от URL и token; +- поддерживать callback успешной загрузки, чтобы UI мог узнать intrinsic size; +- не показывать частично скачанный файл; +- для full-screen использовать fit; +- для chat preview использовать crop только когда aspect ratio контейнера отличается от изображения. + +### Admin panel + +Admin UI должен быть web-страницей внутри сервера. + +Возможности: + +- dashboard server status; +- список пользователей; +- список чатов; +- список сообщений; +- список вложений; +- скачивание вложения; +- удаление вложения; +- удаление пользователя; +- удаление чата; +- фильтры и поиск; +- отображение размера файлов и количества записей. + +### Обновления приложения + +Нужен механизм обновления Android-клиента: + +- сервер или отдельный catalog endpoint отдает manifest; +- приложение проверяет latest release; +- сравнивает versionName/versionCode; +- скачивает APK; +- проверяет SHA-256; +- запускает Android package installer; +- записывает установленную версию только после успешной установки. + +Manifest должен содержать: + +- slug; +- name; +- version; +- androidVersionCode; +- channel; +- platform; +- packageKind; +- downloadPath; +- packageSizeBytes; +- sha256; +- notes; +- publishedAt. + +Важно: + +- все APK-обновления должны быть подписаны одним и тем же release/upload key; +- нельзя публиковать APK с новым случайным ключом поверх уже установленного приложения. + +### Хранение файлов + +Сервер должен: + +- сохранять вложения в отдельную директорию; +- генерировать безопасные имена файлов; +- хранить оригинальное имя отдельно; +- не доверять client filename; +- определять content type по заголовку и расширению; +- удалять физический файл при удалении attachment/message/chat/user; +- запрещать path traversal; +- отдавать файл только авторизованным участникам чата. + +### Docker и deployment + +Нужно подготовить: + +- `Dockerfile.server`; +- docker-compose для сервера и БД, если нужно; +- appsettings для production/development; +- переменные окружения для JWT, storage path, admin password, SMTP, Firebase; +- scripts для smoke tests; +- scripts для публикации Android APK. + +### Тестирование + +Обязательные тесты backend: + +- auth flow; +- create direct chat; +- send message; +- upload attachment; +- download attachment access control; +- group member permissions; +- channel post permissions; +- reactions; +- stories visibility; +- bot sendMessage/sendPhoto; +- admin delete user/chat/attachment; +- push dispatcher. + +Обязательные проверки Android: + +- compileDebugKotlin; +- assembleRelease; +- отправка текста; +- отправка фото; +- фото в чате без белого прямоугольника; +- full-screen preview открывает полное фото; +- получатель видит фото без частичного JPEG; +- отправка альбома; +- voice note; +- video preview/player; +- push notification; +- login/session restore. + +### Критерии приемки MVP + +MVP считается готовым, если: + +- пользователь может зарегистрироваться/войти; +- видит список чатов; +- может создать личный чат; +- может отправить и получить текст в real-time; +- может отправить фото; +- фото отображается в чате без белого пузыря и без поломанного partial preview; +- фото открывается на весь экран; +- можно отправить файл; +- можно отправить voice note; +- есть push для новых сообщений; +- есть локальный кэш сообщений; +- backend проверяет права доступа; +- Android APK собирается release-сборкой; +- APK обновляется через manifest/catalog. + +### Критерии приемки полной версии + +Полная версия считается готовой, если дополнительно есть: + +- группы; +- каналы; +- обсуждения каналов; +- сторис; +- аудио/видео звонки; +- боты; +- глобальный поиск; +- админка; +- настройки приватности; +- настройки чатов; +- media gallery/materials; +- transfer queue; +- Argus-like publication flow; +- smoke tests; +- базовая документация для запуска. + +## Особые требования к качеству + +- Приложение должно ощущаться быстрым. +- Все сетевые ошибки должны иметь понятное сообщение. +- Нельзя блокировать UI во время upload/download. +- Нельзя показывать недокачанный файл как готовый. +- Нельзя терять draft при повороте/перезапуске. +- Нельзя отдавать вложения неучастникам чата. +- Нельзя публиковать Android update с другим signing certificate. +- Нужно очищать временные файлы. +- Нужно избегать больших белых полей вокруг медиа. +- Нужно хранить секреты только в env/local ignored files. + +## Подсказка для нейросети-разработчика + +Если ты создаешь проект по этому ТЗ, сначала сгенерируй архитектуру и каркас: + +1. Backend API + database entities. +2. Android app shell + auth/session. +3. Chat list + chat screen. +4. Realtime messages. +5. Attachments upload/download. +6. Correct image rendering and cache safety. +7. Push notifications. +8. Groups/channels/stories/calls/bots. +9. Admin panel. +10. Release/update flow. + +Не начинай с лендинга. Первый экран приложения после входа - список чатов. Если пользователь не авторизован - экран входа. + +Главный UX-ориентир: это должен быть рабочий мессенджер, а не демонстрационная страница.