diff --git a/AGENT.md b/AGENT.md index f2496a6..5617c46 100644 --- a/AGENT.md +++ b/AGENT.md @@ -28,6 +28,8 @@ docker cp lampac:/lampac/module/<Имя> "mods/<Имя>" См. также официальные шаги: [Включить модуль](https://docs.lampac.dev/modules/overview), [Пользовательские модули](https://docs.lampac.dev/maintenance/custom-modules). +Сейчас в `mods/`: `AdminPanel`, `Tracks`, `Tg-notify.bot`, `TelegramAuth`, `TelegramAuthBot`, `WatchTogether`. **`mods/Music` удалён** (remote-коммит `remove Music`): в образе у Music `manifest.enable: false`, поэтому модуль не грузится, `/music.js` → 404, секция `"Music"` и плагин убраны из `init.conf`. + ### 2. Механика загрузки модуля (4 независимых фильтра) Модуль грузится, только если выполнены **все**: @@ -112,11 +114,14 @@ docker exec lampac sh -c '...' # осмотр состояния в конте ### 10. Безопасность (не публиковать) -`config/passwd`, cookie `accspasswd`, заголовок `lcrqpasswd`, `bot_token` в `TelegramBot`. Маршруты `/adminpanel`, `/weblog`, `Tracks`, `Catalog`, `DLNA`, proxy-модули — только для доверенной сети без WAF не открывать. +`config/passwd`, cookie `accspasswd`, заголовок `lcrqpasswd`, `bot_token` в `TelegramBot`, `bot_token` в `TelegramAuthBot`, `mutations_api_secret`. Маршруты `/adminpanel`, `/weblog`, `Tracks`, `Catalog`, `DLNA`, proxy-модули — только для доверенной сети без WAF не открывать. + +**Секреты в git не попадают:** в закоммиченном `config/init.conf` токены и секрет должны быть **пустыми** (так делает и remote — коммит `remove`), реальные значения живут только в рабочем файле на сервере (копия до мержа — `/root/init.conf.local`). Перед `git add config/init.conf`: `git diff config/init.conf | grep -E ":[0-9]{8,}:AA|\"mutations_api_secret\": \"[a-f0-9]{16}"` → должно быть пусто. ### 11. Tg-notify.bot (Telegram-бот) - Механика: long polling по `TelegramBot.bot_token`; если токен пуст — polling не стартует (в логе `[TelegramBot] Установите bot_token…`, сервер работает). После правки секции: `docker compose restart lampac`. +- **Токен только локально:** в git поле пустое (правило 10). На сервере в `init.conf` вставлен `@KlinkerZabbixBot` — после `git pull`/переписывания конфига проверять, что polling снова стартовал (`[TelegramBot] @KlinkerZabbixBot запущен!`). Второй экземпляр на том же токене даёт `Conflict: terminated by other getUpdates request`. - Три подсистемы: трекер серий (Trakt→TMDB), трекер озвучек (параллельно Mirage/Collaps/VideoHub через `lampac_host`: `/lite/mirage`, `/lite/collaps`, `/lite/cdnvideohub`), клиентский плагин `tg-notify.js` (вкладка «TG Подписки», перехват 🔔). Полный разбор — в README.md. - Данные подписок: `database/tgnotify/{users.json,subscriptions.json}` (том `./database/`, в .gitignore). Не «чинить» вручную — это живые данные бота. - Диагностика: `GET /tg-notify.js` (200 без авторизации), `GET /api/tg/voices` (200), `/api/tg/*` остальной — требует `user_uid`. Озвучки могут не работать, если балансеры недоступны (проверить `GET /lite/mirage` — тело `disable`/ошибка, `/lite/collaps` — 503); тогда уведомления приходят только о выходе серий. @@ -124,6 +129,18 @@ docker exec lampac sh -c '...' # осмотр состояния в конте ### 12. TelegramAuth / TelegramAuthBot (вход через Telegram) -- Не путать с Tg-notify.bot (уведомления): эта пара — **аутентификация** (вход в Lampa через Telegram вместо пароля), группа Community, в образе выключена (`SkipModules` + `enable: false`). -- Гейт (`LampaWeb.telegramAuthGate`) активен только при `accsdb.enable: true` + `telegramAuthGate.enabled: true` + непустом `botUsername`, иначе тихий откат на `deny.js`. У нас `accsdb` не включён — связка неактуальна, включать только вместе с `accsdb`. -- `mutations_api_secret` одинаковый в обоих модулях, ненулевой в проде; `bot_token` — в `init.conf`, не коммитить (см. правило 10). +- Не путать с Tg-notify.bot (уведомления): эта пара — **аутентификация** (вход в Lampa через Telegram вместо пароля), группа Community. Копии в `mods/TelegramAuth/` и `mods/TelegramAuthBot/`, манифесты `enable: true`, из `SkipModules` убраны (у нас `SkipModules: []`). +- **Сейчас: API-режим.** `TelegramAuth.enable: false` → `accsdb` не поднимается, гейт неактивен, доступ не режется; работают привязки, `/tg/auth/*`, админ-API. `TelegramAuthBot` — бот **`@GatusMonBot`**, long polling идёт (`Telegram API OK`). +- Гейт (`LampaWeb.telegramAuthGate`) активен только при `accsdb.enable: true` + `telegramAuthGate.enabled: true` + непустом `botUsername`, иначе тихий откат на `deny.js`. Включать **только вместе с `accsdb`**, и сначала связать свои устройства через бота — иначе текущие UID получат отказ. +- `mutations_api_secret` одинаковый в обоих модулях, ненулевой; `bot_token` — в `init.conf`, **не коммитить** (правило 10): в git оба поля пустые. +- `owner_telegram_ids: [143672]` → владелец создаётся как `admin` при старте. `auto_provision_users: true` + `auto_provision_activate_immediately: false` → новые привязки ждут одобрения (`/users` в боте, кнопки Принять/Отклонить). +- Данные: `database/tgauth/{users.json,admins.json,user_langs.json}` (том `lampac_database`, владелец `1000:1000`). Живые данные — не «чинить» руками. +- Проверка: `GET /tg/auth/status?uid=x` → 200; `GET /tg/auth/admin/users` → 401 без заголовка, 200 с `X-TelegramAuth-Mutations-Secret`. + +### 13. WatchTogether (комнаты синхронного просмотра) + +- Копия в `mods/WatchTogether/` (`enable: true`), секция `WatchTogether` в `init.conf` (`enable`, `allow_anonymous: true`, `relay_max_channels: 500`, `relay_max_clients_per_channel: 32`, `gc_empty_timeout_minutes: 30`, `gc_max_lifetime_hours: 12`). +- Клиентский плагин зарегистрирован в `LampaWeb.customPlugins`: `{localhost}/watchtogether.js`. +- Роуты: `GET /watchtogether.js` → 200, `GET /watch_together/` → 200, `WS /wt/c/{channel}` → 400 на обычном GET (нужен WebSocket upgrade — это штатно, handshake даёт 101). +- Lampac пассивно хранит состояние комнаты и становится **хостом-«тенью»** (`ShadowHost.cs`), если создатель отключился; роль возвращается ему при переподключении. Лаг ≤ 2 с. +- При появлении reverse proxy: на `/wt/c/` обязателен WebSocket upgrade, отключённый buffering и таймаут больше длительности просмотра. diff --git a/README.md b/README.md index 0c2f645..46c41ab 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,56 @@ docker compose restart # после правки config/init.conf или По умолчанию сервер слушает порт **9118**; если порт занят — меняй `listen.port` в `config/init.conf` до первого запуска. +## Первая установка: SQLite не может создать базу в named volume + +**Симптом.** Свежий `docker compose up -d` уходит в crash-loop (`Restarting (139)`, healthcheck `unhealthy`), в логах: + +``` +Unhandled exception. Microsoft.Data.Sqlite.SqliteException (0x80004005): +SQLite Error 14: 'unable to open database file'. + at Microsoft.Data.Sqlite.SqliteConnection.Open() + at Microsoft.EntityFrameworkCore.Sqlite.Storage.Internal.SqliteDatabaseCreator.Create() + at Microsoft.EntityFrameworkCore.RelationalDatabaseCreator.EnsureCreated() + at Core.Program.Run(String[] args) +``` + +**Причина.** Процесс в контейнере работает от uid `1000` (пользователь образа `lampac`), а Docker при первом создании named volume делает его владельцем `root:root`. Смонтированный каталог `/lampac/database` поэтому недоступен на запись для `lampac`, `sqlite3` получает `EACCES` и приложение падает сразу после `EnsureCreated()` — база не создаётся ни разу, поэтому изнутри контейнера это не лечится. + +Та же первопричина у второй ошибки первого запуска: + +``` +System.UnauthorizedAccessException: Access to the path '/lampac/passwd' is denied. +``` + +`config/passwd` монтируется файлом и тоже должен принадлежать uid `1000`. + +**Лечение** — на хосте, от root: + +```bash +# тома (первый запуск или миграция/копия томов) +chown -R 1000:1000 /var/lib/docker/volumes/lampac_database/_data \ + /var/lib/docker/volumes/lampac_cache/_data + +# файл пароля (владелец 1000, права 600 — читает только процесс контейнера) +chown 1000:1000 config/passwd && chmod 600 config/passwd + +docker compose up -d # или docker compose restart, если контейнер уже создан +``` + +**Проверка:** + +```bash +docker compose ps # Up (healthy) +docker logs --since 2m lampac | grep -ci unhandled # 0 +ls -ln /var/lib/docker/volumes/lampac_database/_data/Sisi.sql # uid/gid 1000 +``` + +> После лечения в `lampac_database` должны появиться `Sisi.sql`, `Sync.sql`, `TimeCode.sql`, `tgnotify/`, `tracks/`, `storage/` — это и есть признак, что базы созданы. + +Проверить uid процесса в контейнере: `docker exec lampac id` → `uid=1000(lampac)`. + +Правило: **любой bind-mount файл/каталог, в который пишет контейнер, должен принадлежать `1000:1000`** (см. `.gitignore` для `config/passwd`, `dlna/`). + ## Структура проекта | Путь | Назначение | @@ -24,7 +74,7 @@ docker compose restart # после правки config/init.conf или | `config/init.conf` | Конфигурация сервера (JSON, монтируется в `/lampac/init.conf`) | | `config/passwd` | Пароль root для `/adminpanel` и `/weblog` (не в git) | | volume `lampac_cache` | Кеш (`/lampac/cache`) — Docker named volume, не файловый том | -| volume `lampac_database` | SQLite-базы Sync, TimeCode, Music, tg-notify (`/lampac/database`) — Docker named volume | +| volume `lampac_database` | SQLite-базы Sync, TimeCode, tg-notify, tracks, storage (`/lampac/database`) — Docker named volume | | `dlna/` | Локальные медиа для DLNA (`/lampac/data/dlna`) | | `plugins/extensions.json` | Каталог плагинов для веб-клиента (раздел «Плагины»), отдаётся через `/extensions` | | `mods/<Имя>/` | Пользовательские копии встроенных модулей с `enable: true` | @@ -53,14 +103,18 @@ docker compose restart # после правки config/init.conf или |---|---|---| | `Tracks` | `/tracks.js`, `/ffprobe` — субтитры и дорожки | `"Tracks": { "enable": true }` | | `AdminPanel` | `/adminpanel` — редактор init.conf, current.conf, users.json | `"AdminPanel": { "enable": true }` | -| `Music` | `/music.js`, `/music/*` — музыкальный каталог, плейлисты, стримы | секция `"Music"` (базовые дефолты) | | `Tg-notify.bot` | `/tg-notify.js`, `/api/tg/*` — уведомления о новых сериях в Telegram | секция `"TelegramBot"` | +| `TelegramAuth` | `/tg/auth/*` — привязка UID устройства к учётке Telegram, HTTP API статуса | секция `"TelegramAuth"` (`enable: false` → без accsdb-гейта) | +| `TelegramAuthBot` | `@GatusMonBot` — long-polling бот привязки и админ-команды | секция `"TelegramAuthBot"` | +| `WatchTogether` | `/watchtogether.js`, `/watch_together/`, WS `/wt/c/` — комнаты синхронного просмотра | секция `"WatchTogether"` | -Клиентские плагины `music.js` и `tg-notify.js` зарегистрированы в `LampaWeb.customPlugins`; `{localhost}` в URL автоматически подставляется сервером. +Клиентские плагины `tg-notify.js` и `watchtogether.js` зарегистрированы в `LampaWeb.customPlugins`; `{localhost}` в URL автоматически подставляется сервером. + +> **Модуль `Music` удалён из `mods/`** (коммит `remove Music`): в образе у него `manifest.enable: false`, поэтому `/music.js` → 404, секция `"Music"` из `init.conf` убрана, плагин из `customPlugins` убран. Вернуть музыку — той же механикой: скопировать `lampac:/lampac/module/Music` в `mods/Music`, поставить `enable: true`, вернуть секцию и плагин. ### Ещё можно включить тем же способом -В образе выключены также: `DatabaseEditor`, `ExternalBind`, `LogUserRequest-Lite`, `Transcoding`, `WatchTogether`, `TelegramAuth`, `TelegramAuthBot`. +В образе выключены также: `DatabaseEditor`, `ExternalBind`, `LogUserRequest-Lite`, `Transcoding`, `Music`. ## Конфигурация @@ -187,8 +241,8 @@ docker exec lampac rm -rf /lampac/cache/static/59 # bucket /extensions; либ ### Состояние на нашем инстансе -- Модуль загружен, `/tg-notify.js` → 200, плагин уже в `LampaWeb.customPlugins`, вкладка «TG Подписки» видна. -- `bot_token` **пуст** → long polling не работает (лог: `[TelegramBot] Установите bot_token…`); уведомления придут только после заполнения токена. +- Модуль загружен, `/tg-notify.js` → 200, плагин уже в `LampaWeb.customPlugins`, вкладка «TG Подписки» видна; аккаунт привязан (`database/tgnotify/users.json`). +- `bot_token` заполняется **только локально**: в git поле пустое (правило 10), токен вставлен в `config/init.conf` на сервере и при работе `@KlinkerZabbixBot` (`[TelegramBot] @KlinkerZabbixBot запущен!`). После любого `git pull`/правки конфига токен нужно вернуть на место — иначе polling не стартует. - Трекинг озвучек зависит от балансиров: на текущий момент `GET /lite/mirage` → тело `disable`, `GET /lite/collaps` → `503` — до тех пор, пока они не будут доступны (или не включится `lampac_token` при защите), бот будет уведомлять только о выходе серий (TMDB/Trakt). ## TelegramAuth + TelegramAuthBot: вход в Lampa через Telegram @@ -223,7 +277,29 @@ docker exec lampac rm -rf /lampac/cache/static/59 # bucket /extensions; либ `mutations_api_secret` должен совпадать в обоих модулях и быть ненулевым — защищает `bind/complete` и бот-админку; вместе с `bot_token` не публиковать. Оба модуля выключены в `base.conf` (`SkipModules`) и в манифестах (`enable: false`). -**Состояние у нас:** выключены, `accsdb` в `init.conf` не включён → гейт неактивен, используем беззащитный доступ + root-пароль AdminPanel. Включать связку имеет смысл вместе с `accsdb` (защита сервера учётными записями). +**Состояние у нас:** модули **включены** (копии в `mods/TelegramAuth/` и `mods/TelegramAuthBot/`, манифесты `enable: true`, `SkipModules` пуст), работают в **API-режиме**: `TelegramAuth.enable: false` → `accsdb` не поднимается, гейт неактивен, доступ не режется. Бот `@GatusMonBot` — `Telegram API OK`, long polling идёт; `owner_telegram_ids: [143672]` создаёт владельца ролью `admin` при старте, `auto_provision_users: true` + `auto_provision_activate_immediately: false` — новые привязки висят в pending до одобрения кнопкой у админа. Секрет `mutations_api_secret` (одинаков в обеих секциях) и `bot_token` лежат в `config/init.conf` **только на сервере**. + +**Порядок включения гейта (когда решим):** поставить `TelegramAuth.enable: true` → в `LampaWeb` секцию `telegramAuthGate` (`enabled: true`, `botUsername: "GatusMonBot"`) → подключить клиентский оверлей (`deny.js` / `telegram_auth_gate.js`) → **сначала связать свои устройства через бота**, иначе текущие UID получат отказ. + +## WatchTogether: комнаты синхронного просмотра + +Модуль из группы «клиент и контент»: синхронизирует `play/pause/seek` между участниками комнаты. Подробный разбор — [официальная документация](https://docs.lampac.dev/modules/watch-together) и `mods/WatchTogether/README.md`. + +**Как устроено.** Lampac не отдаёт медиа — он пассивный брокер: хранит состояние комнаты и рассылает команды. Сами видео каждый клиент тянет своим источником, поэтому подходит любой контент и совместим с экосистемой LParty (`lparty.js`, `lparty-web`, JustPlus Player). + +| Клиент | Адрес | +|---|---| +| Плагин Lampa | `{localhost}/watchtogether.js` (в `LampaWeb.customPlugins`) | +| Веб-гость без установки Lampa | `http://<адрес>/watch_together/?room=` | +| LParty relay (WebSocket) | `ws://<адрес>/wt/c/` | + +Фирменная фича — **«тень» (`ShadowHost.cs`)**: сервер слушает канал и копирует url, позицию, скорость и список участников, виден в зрителях как `Lampac`, но ничего не рассылает. Как только хост рвётся, он мгновенно шлёт кадр `host` и продолжает timeline (лаг ≤ 2 с), попытки перехвата отклоняет повторным `host=сервер`; создатель вернулся — отдаёт роль обратно и снова уходит в тень. Пустые комнаты GC через `gc_empty_timeout_minutes`, любые — через `gc_max_lifetime_hours`. + +**Конфиг** (секция `WatchTogether` в `init.conf`): `enable: true`, `allow_anonymous: true` (плагин и веб-гость без аккаунта), `relay_max_channels: 500`, `relay_max_clients_per_channel: 32`, `gc_empty_timeout_minutes: 30`, `gc_max_lifetime_hours: 12`. + +**Требование к reverse proxy:** на `/wt/c/` нужен WebSocket upgrade, отключённый response buffering и таймаут **больше длительности просмотра**. Сейчас прокси нет — relay работает напрямую, ничего настраивать не нужно. + +**Проверка:** `GET /watchtogether.js` → 200, `GET /watch_together/` → 200, `WS /wt/c/` → 400 на обычном GET (штатно: нужен upgrade; handshake даёт 101). ## Обновление Lampac @@ -234,7 +310,7 @@ docker compose up -d Перед обновлением проверить, что в `docker-compose.yaml` раскомментированы нужные тома. Данные сохраняются в примонтированных томах: `config/`, `cache/`, `database/`, `dlna/`, `mods/`. -**Образ закреплён точным digest** (`1.68.0@sha256:752dafd8…`): тег `1.68.0` на ghcr — mutable, его переиздавали, и в новой сборке `mods/Music` (копия из старой сборки) не компилируется (`MusicMetadataCacheService` не найден) — сервер уходит в crash-loop. Чтобы обновить образ: взять новый digest, пересобрать копии модулей в `mods/` из нового образа (иначе Roslyn-компиляция упадёт) и заменить digest в `docker-compose.yaml`. +**Образ закреплён точным digest** (`1.68.0@sha256:752dafd8…`): тег `1.68.0` на ghcr — mutable, его переиздавали, и в новой сборке **копии модулей в `mods/`** (Roslyn компилирует их на каждом старте) перестают собираться — `CS0103` по символам из старого билда и crash-loop сервера. Чтобы обновить образ: взять новый digest, пересобрать копии модулей в `mods/` из нового образа (`docker cp lampac:/lampac/module/<Имя> mods/<Имя>` + вернуть `"enable": true`) и заменить digest в `docker-compose.yaml`. Не делать «просто» `docker compose pull` без фиксации digest. ## Безопасность @@ -242,5 +318,6 @@ docker compose up -d - Не публикуй в интернет без аутентификации, WAF и ограничения сети: `/adminpanel`, `/weblog`, `/database-editor`, `Tracks`, `Transcoding`, `Catalog`, `DLNA`, proxy-модули. - Нигде не раскрывай `config/passwd`, cookie `accspasswd`, заголовок `lcrqpasswd`. +- **Секреты в `config/init.conf` не коммитятся**: `TelegramBot.bot_token`, `TelegramAuthBot.bot_token`, `mutations_api_secret`. В git они должны быть пустыми — реальные значения живут только в файле на сервере (`/root/init.conf.local` — резервная копия перед мержем). Перед `git add config/init.conf` проверять `git diff` на токены (`git diff config/init.conf | grep -E ":[0-9]{8,}:AA"` → пусто). - Для публикации за reverse proxy: [deployment/reverse-proxy](https://docs.lampac.dev/deployment/reverse-proxy) (HTTPS в ответах, реальный IP клиента, WebSocket, SSE, streaming). - `shm_size: 4096mb` — рекомендованный минимум для Playwright/Chromium. diff --git a/config/init.conf b/config/init.conf index 6ef4e69..767ba7b 100644 --- a/config/init.conf +++ b/config/init.conf @@ -33,7 +33,16 @@ "backup": true }, "customPlugins": [ - { "url": "{localhost}/tg-notify.js", "status": 1, "name": "Tg-notify" } + { + "url": "{localhost}/tg-notify.js", + "status": 1, + "name": "Tg-notify" + }, + { + "url": "{localhost}/watchtogether.js", + "status": 1, + "name": "WatchTogether" + } ] }, "chromium": { @@ -56,5 +65,54 @@ "history": { "enable": false } + }, + "TelegramAuth": { + "enable": false, + "data_dir": "database/tgauth", + "legacy_import_path": "database/legacy_tgauth", + "enable_import": true, + "enable_cleanup": true, + "mutations_api_secret": "", + "owner_telegram_ids": [ + 143672 + ], + "auto_provision_users": true, + "auto_provision_role": "user", + "auto_provision_lang": "ru", + "auto_provision_expires_days": 0, + "auto_provision_activate_immediately": false, + "accsdb_sync_group_admin": 100, + "accsdb_sync_group_user": 0, + "max_active_devices_per_user": 5, + "limit_map": [ + { + "pattern": "^/tg/auth", + "map": { + "limit": 25, + "second": 1 + } + } + ] + }, + "TelegramAuthBot": { + "enable": true, + "bot_token": "", + "lampac_base_url": "http://127.0.0.1:80", + "request_timeout_sec": 10, + "service_display_name": "Lampac NextGen", + "mutations_api_secret": "", + "admin_chat_ids": [], + "owner_telegram_ids": [ + 143672 + ], + "notify_admins_on_pending_provision": true + }, + "WatchTogether": { + "enable": true, + "allow_anonymous": true, + "relay_max_channels": 500, + "relay_max_clients_per_channel": 32, + "gc_empty_timeout_minutes": 30, + "gc_max_lifetime_hours": 12 } -} +} \ No newline at end of file diff --git a/mods/TelegramAuth/Controllers/TelegramAuthController.cs b/mods/TelegramAuth/Controllers/TelegramAuthController.cs new file mode 100644 index 0000000..d46f2df --- /dev/null +++ b/mods/TelegramAuth/Controllers/TelegramAuthController.cs @@ -0,0 +1,519 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; +using Shared; +using Shared.Attributes; +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using TelegramAuth.Models; +using TelegramAuth.Services; + +namespace TelegramAuth.Controllers +{ + [Authorization("access denied")] + public class TelegramAuthController : BaseController + { + public const string MutationsSecretHeaderName = "X-TelegramAuth-Mutations-Secret"; + + static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + NullValueHandling = NullValueHandling.Ignore + }; + + static TelegramAuthStore store => ModInit.Store; + + [HttpGet] + [AllowAnonymous] + [Route("/tg/auth/status")] + public ActionResult Status([FromQuery] string uid) + { + if (string.IsNullOrWhiteSpace(uid)) + return JsonError(400, "uid is required"); + + var status = store.GetStatus(uid); + return JsonOk(status); + } + + [HttpGet] + [AllowAnonymous] + [Route("/tg/auth/me")] + public ActionResult Me([FromQuery] string uid) + { + if (string.IsNullOrWhiteSpace(uid)) + return JsonError(400, "uid is required"); + + var user = store.FindByUid(uid); + if (user == null) + return JsonError(404, "user not found for uid"); + + return JsonOk(user); + } + + [HttpPost] + [AllowAnonymous] + [Route("/tg/auth/bind/start")] + public ActionResult BindStart([FromBody] BindStartRequest? request) + { + if (request == null || string.IsNullOrWhiteSpace(request.Uid)) + return JsonError(400, "uid is required"); + + var response = new + { + ok = true, + pending = true, + uid = request.Uid, + message = "Привяжи UID в Telegram-боте." + }; + + return JsonOk(response); + } + + [HttpPost] + [AllowAnonymous] + [Route("/tg/auth/bind/complete")] + public ActionResult BindComplete([FromBody] BindCompleteRequest? request) + { + if (HasConfiguredMutationsSecret() && !HasMutationAccess()) + return MutationUnauthorized(); + + if (request == null || string.IsNullOrWhiteSpace(request.Uid) || string.IsNullOrWhiteSpace(request.TelegramId)) + return JsonError(400, "uid and telegramId are required"); + + try + { + var bindOutcome = store.BindDevice(request.TelegramId, request.Uid, request.Username, request.DeviceName, "manual-complete"); + return JsonOk(new + { + ok = true, + message = bindOutcome.PendingAdminApproval + ? "Устройство привязано; доступ включит администратор" + : "Устройство привязано", + uid = request.Uid, + telegramId = request.TelegramId, + newUserProvisioned = bindOutcome.NewUserProvisioned, + pendingAdminApproval = bindOutcome.PendingAdminApproval + }); + } + catch (TelegramAuthBindException ex) when (ex.FailureKind == TelegramAuthBindFailureKind.UserNotFound) + { + return JsonError(404, "user not found", "Включите auto_provision_users в TelegramAuth или добавьте пользователя в users.json."); + } + catch (TelegramAuthBindException ex) when (ex.FailureKind == TelegramAuthBindFailureKind.UserDisabled) + { + return JsonError(403, "user disabled", "Аккаунт отключён администратором."); + } + catch (Exception ex) + { + return JsonError(500, "bind failed", ex.Message); + } + } + + [HttpGet] + [AllowAnonymous] + [Route("/tg/auth/user/by-telegram")] + public ActionResult UserByTelegram([FromQuery] string telegramId) + { + if (string.IsNullOrWhiteSpace(telegramId)) + return JsonError(400, "telegramId is required"); + + var user = store.FindByTelegramId(telegramId); + if (user == null) + { + return JsonOk(new + { + found = false, + telegramId + }); + } + + var registrationPending = TelegramAuthStore.IsRegistrationPending(user); + return JsonOk(new + { + found = true, + telegramId = user.TelegramId, + username = user.TgUsername, + role = user.Role, + lang = user.Lang, + approvedBy = user.ApprovedBy, + createdAt = user.CreatedAt, + expiresAt = user.ExpiresAt, + disabled = user.Disabled, + registrationPending, + active = store.IsActive(user), + deviceCount = user.Devices.Count(d => d.Active), + maxDevices = store.GetMaxDevices(user) + }); + } + + [HttpGet] + [AllowAnonymous] + [Route("/tg/auth/admin/users")] + public ActionResult AdminListUsers() + { + if (!HasMutationAccess()) + return MutationUnauthorized(); + + var users = store.GetUsers() + .OrderBy(u => u.TelegramId, StringComparer.Ordinal) + .Select(u => new + { + telegramId = u.TelegramId, + username = u.TgUsername, + role = u.Role, + disabled = u.Disabled, + registrationPending = TelegramAuthStore.IsRegistrationPending(u), + active = store.IsActive(u), + expiresAt = u.ExpiresAt, + deviceCount = u.Devices.Count(d => d.Active), + accs = u.Accs == null + ? null + : new + { + u.Accs.group, + u.Accs.IsPasswd, + u.Accs.ban, + u.Accs.ban_msg, + u.Accs.comment, + u.Accs.ids, + u.Accs.@params + } + }) + .ToList(); + + return JsonOk(new { ok = true, users }); + } + + [HttpGet] + [AllowAnonymous] + [Route("/tg/auth/admin/user")] + public ActionResult AdminGetUser([FromQuery] string telegramId) + { + if (!HasMutationAccess()) + return MutationUnauthorized(); + + if (string.IsNullOrWhiteSpace(telegramId)) + return JsonError(400, "telegramId is required"); + + var user = store.FindByTelegramId(telegramId.Trim()); + if (user == null) + return JsonError(404, "user not found"); + + return JsonOk(new + { + ok = true, + telegramId = user.TelegramId, + username = user.TgUsername, + role = user.Role, + lang = user.Lang, + approvedBy = user.ApprovedBy, + createdAt = user.CreatedAt, + expiresAt = user.ExpiresAt, + disabled = user.Disabled, + registrationPending = TelegramAuthStore.IsRegistrationPending(user), + active = store.IsActive(user), + deviceCount = user.Devices.Count(d => d.Active), + maxDevices = store.GetMaxDevices(user), + accs = user.Accs, + devices = user.Devices.Select(d => new + { + uid = d.Uid, + name = d.Name, + active = d.Active, + linkedAt = d.LinkedAt, + lastSeenAt = d.LastSeenAt, + source = d.Source + }).ToList() + }); + } + + [HttpPost] + [AllowAnonymous] + [Route("/tg/auth/admin/user/patch")] + public async Task AdminPatchUser() + { + if (!HasMutationAccess()) + return MutationUnauthorized(); + + string raw; + using (var reader = new StreamReader(Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true)) + raw = await reader.ReadToEndAsync().ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(raw)) + return JsonError(400, "body is required"); + + JObject body; + try + { + body = JObject.Parse(raw); + } + catch (JsonReaderException) + { + return JsonError(400, "invalid json"); + } + + var tid = body.Value("telegramId"); + if (string.IsNullOrWhiteSpace(tid)) + return JsonError(400, "telegramId is required"); + + var outcome = store.TryAdminPatchUser(tid.Trim(), body, out var err); + if (outcome == TelegramAuthStore.AdminPatchUserOutcome.NotFound) + return JsonError(404, "user not found"); + if (outcome == TelegramAuthStore.AdminPatchUserOutcome.InvalidPayload) + return JsonError(400, "invalid payload", err); + + return JsonOk(new { ok = true, telegramId = tid.Trim() }); + } + + [HttpPost] + [AllowAnonymous] + [Route("/tg/auth/admin/user/disabled")] + public ActionResult AdminSetUserDisabled([FromBody] AdminSetUserDisabledRequest? request) + { + if (!HasMutationAccess()) + return MutationUnauthorized(); + + if (request == null || string.IsNullOrWhiteSpace(request.TelegramId)) + return JsonError(400, "telegramId is required"); + + var outcome = store.TrySetUserDisabled(request.TelegramId.Trim(), request.Disabled); + if (outcome == TelegramAuthStore.SetUserDisabledOutcome.NotFound) + return JsonError(404, "user not found"); + if (outcome == TelegramAuthStore.SetUserDisabledOutcome.CannotDisableAdmin) + return JsonError(403, "cannot disable admin", "Нельзя отключить учётную запись с ролью admin."); + + return JsonOk(new + { + ok = true, + telegramId = request.TelegramId.Trim(), + disabled = request.Disabled + }); + } + + [HttpPost] + [AllowAnonymous] + [Route("/tg/auth/admin/user/pending")] + public ActionResult AdminResolveRegistrationPending([FromBody] AdminPendingDecisionRequest? request) + { + if (!HasMutationAccess()) + return MutationUnauthorized(); + + if (request == null || string.IsNullOrWhiteSpace(request.TelegramId)) + return JsonError(400, "telegramId is required"); + + var tid = request.TelegramId.Trim(); + if (request.Approve) + { + var outcome = store.TryApproveRegistrationPending(tid); + if (outcome == TelegramAuthStore.PendingDecisionOutcome.NotFound) + return JsonError(404, "user not found"); + if (outcome == TelegramAuthStore.PendingDecisionOutcome.NotPending) + return JsonError(400, "not pending", "Пользователь не в статусе ожидания подтверждения."); + + return JsonOk(new { ok = true, telegramId = tid, approved = true }); + } + else + { + var outcome = store.TryRejectRegistrationPending(tid); + if (outcome == TelegramAuthStore.PendingDecisionOutcome.NotFound) + return JsonError(404, "user not found"); + if (outcome == TelegramAuthStore.PendingDecisionOutcome.NotPending) + return JsonError(400, "not pending", "Пользователь не в статусе ожидания подтверждения."); + if (outcome == TelegramAuthStore.PendingDecisionOutcome.CannotRejectAdmin) + return JsonError(403, "cannot reject admin"); + + return JsonOk(new { ok = true, telegramId = tid, rejected = true }); + } + } + + [HttpGet] + [AllowAnonymous] + [Route("/tg/auth/devices")] + public ActionResult Devices([FromQuery] string telegramId) + { + if (string.IsNullOrWhiteSpace(telegramId)) + return JsonError(400, "telegramId is required"); + + var user = store.FindByTelegramId(telegramId); + if (user == null) + return JsonError(404, "user not found"); + + return JsonOk(new + { + telegramId = user.TelegramId, + username = user.TgUsername, + devices = user.Devices + }); + } + + [HttpPost] + [AllowAnonymous] + [Route("/tg/auth/device/unbind")] + public ActionResult Unbind([FromBody] DeviceUnbindRequest? request) + { + if (request == null || string.IsNullOrWhiteSpace(request.Uid) || string.IsNullOrWhiteSpace(request.TelegramId)) + return JsonError(400, "telegramId and uid are required"); + + var outcome = store.TryUnbindDevice(request.TelegramId.Trim(), request.Uid.Trim()); + if (outcome == TelegramAuthStore.UnbindDeviceOutcome.UserNotFound) + return JsonError(404, "user not found"); + if (outcome == TelegramAuthStore.UnbindDeviceOutcome.DeviceNotFound) + return JsonError(404, "device not found", "UID не найден среди устройств этого пользователя."); + + return JsonOk(new { ok = true, uid = request.Uid.Trim() }); + } + + [HttpPost] + [AllowAnonymous] + [Route("/tg/auth/device/reactivate")] + public ActionResult ReactivateDevice([FromBody] DeviceReactivateRequest? request) + { + if (request == null || string.IsNullOrWhiteSpace(request.TelegramId) || string.IsNullOrWhiteSpace(request.Uid)) + return JsonError(400, "telegramId and uid are required"); + + var outcome = store.TryReactivateDevice(request.TelegramId.Trim(), request.Uid.Trim()); + if (outcome == TelegramAuthStore.ReactivateDeviceOutcome.UserNotFound) + return JsonError(404, "user not found"); + if (outcome == TelegramAuthStore.ReactivateDeviceOutcome.UserDisabled) + return JsonError(403, "user disabled", "Аккаунт отключён администратором."); + if (outcome == TelegramAuthStore.ReactivateDeviceOutcome.DeviceNotFound) + return JsonError(404, "device not found", "UID не найден среди ваших устройств."); + + return JsonOk(new { ok = true, uid = request.Uid.Trim() }); + } + + [HttpPost] + [AllowAnonymous] + [Route("/tg/auth/device/name")] + public ActionResult SetDeviceName([FromBody] DeviceSetNameRequest? request) + { + if (request == null || string.IsNullOrWhiteSpace(request.Uid)) + return JsonError(400, "uid is required"); + + var outcome = store.TrySetActiveDeviceDisplayName(request.Uid, request.Name); + if (outcome == TelegramAuthStore.SetDeviceDisplayNameOutcome.InvalidUid) + return JsonError(400, "uid is required"); + if (outcome == TelegramAuthStore.SetDeviceDisplayNameOutcome.NotFoundOrInactive) + return JsonError(404, "device not found or access inactive", "UID не привязан или срок доступа истёк."); + + return JsonOk(new + { + ok = true, + uid = request.Uid.Trim(), + name = string.IsNullOrWhiteSpace(request.Name) ? (string?)null : request.Name.Trim() + }); + } + + [HttpPost] + [AllowAnonymous] + [Route("/tg/auth/devices/cleanup")] + public ActionResult CleanupDevices() + { + if (!HasMutationAccess()) + return MutationUnauthorized(); + + if (!ModInit.conf.enable_cleanup) + return JsonError(404, "cleanup disabled"); + + try + { + var removed = store.CleanupInactiveDevices(); + return JsonOk(new { ok = true, removed }); + } + catch (Exception ex) + { + return JsonError(500, "cleanup failed", ex.Message); + } + } + + [HttpPost] + [AllowAnonymous] + [Route("/tg/auth/import")] + public ActionResult ImportLegacy() + { + if (!HasMutationAccess()) + return MutationUnauthorized(); + + if (!ModInit.conf.enable_import) + return JsonError(404, "import disabled"); + + var legacyPath = ModInit.conf.legacy_import_path?.Trim(); + if (string.IsNullOrEmpty(legacyPath)) + return JsonError(400, "legacy_import_path is not configured"); + + try + { + var result = store.ImportFromLegacy(legacyPath); + return JsonOk(result); + } + catch (Exception ex) + { + return JsonError(500, "import failed", ex.Message); + } + } + + bool HasConfiguredMutationsSecret() => + !string.IsNullOrEmpty(ModInit.conf.mutations_api_secret?.Trim()); + + bool HasMutationAccess() + { + if (Request.Cookies.TryGetValue("accspasswd", out var passwd) + && SecretEquals(passwd, CoreInit.rootPasswd)) + { + return true; + } + + var expected = ModInit.conf.mutations_api_secret?.Trim(); + if (string.IsNullOrEmpty(expected) + || !Request.Headers.TryGetValue(MutationsSecretHeaderName, out var supplied)) + { + return false; + } + + return SecretEquals(supplied.ToString().Trim(), expected); + } + + static bool SecretEquals(string? supplied, string? expected) + { + if (string.IsNullOrEmpty(supplied) || string.IsNullOrEmpty(expected)) + return false; + + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(supplied), + Encoding.UTF8.GetBytes(expected)); + } + + ContentResult MutationUnauthorized() => JsonError(401, "unauthorized"); + + ContentResult JsonOk(object data) + { + return new ContentResult + { + Content = JsonConvert.SerializeObject(data, JsonSettings), + ContentType = "application/json; charset=utf-8", + StatusCode = 200 + }; + } + + ContentResult JsonError(int status, string error, string? detail = null) + { + var payload = new + { + error, + detail + }; + return new ContentResult + { + Content = JsonConvert.SerializeObject(payload, JsonSettings), + ContentType = "application/json; charset=utf-8", + StatusCode = status + }; + } + } +} diff --git a/mods/TelegramAuth/ModInit.cs b/mods/TelegramAuth/ModInit.cs new file mode 100644 index 0000000..2bc9430 --- /dev/null +++ b/mods/TelegramAuth/ModInit.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using Shared; +using Shared.Models.AppConf; +using Shared.Models.Events; +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; +using Shared.Services; +using TelegramAuth.Models; +using TelegramAuth.Services; + +namespace TelegramAuth +{ + public class ModInit : IModuleLoaded + { + public static TelegramAuthConf conf = new(); + + public static TelegramAuthStore Store { get; private set; } = null!; + + public void Loaded(InitspaceModel initspace) + { + UpdateConfFromInit(); + EventListener.UpdateInitFile += UpdateConfFromInit; + } + + void UpdateConfFromInit() + { + conf = ModuleInvoke.Init("TelegramAuth", new TelegramAuthConf + { + enable = false, + data_dir = null, + legacy_import_path = "", + enable_import = true, + enable_cleanup = true, + max_active_devices_per_user = 0, + mutations_api_secret = "", + owner_telegram_ids = null, + auto_provision_users = false, + auto_provision_role = "user", + auto_provision_lang = "ru", + auto_provision_expires_days = 0, + auto_provision_activate_immediately = false, + accsdb_sync_group_admin = 100, + accsdb_sync_group_user = 0, + limit_map = new List + { + new("^/tg/auth", new WafLimitMap { limit = 5, second = 1 }) + } + }); + + if (CoreInit.conf != null && conf.enable) + CoreInit.conf.accsdb.enable = true; + + Store = new TelegramAuthStore(conf); + Store.EnsureStorage(); + Store.EnsureOwnerUsersAtStartup(); + + if (conf.enable) + ApplyWafLimitMapFromConf(); + } + + void ApplyWafLimitMapFromConf() + { + var waf = CoreInit.conf?.WAF?.limit_map; + if (waf == null) + return; + + var ours = conf.limit_map ?? new List(); + var patterns = new HashSet(StringComparer.Ordinal); + foreach (var m in ours) + { + if (!string.IsNullOrEmpty(m?.pattern)) + patterns.Add(m.pattern); + } + + if (patterns.Count == 0) + return; + + waf.RemoveAll(x => x != null && !string.IsNullOrEmpty(x.pattern) && patterns.Contains(x.pattern)); + foreach (var m in ours) + waf.Insert(0, m); + } + + public void Dispose() + { + EventListener.UpdateInitFile -= UpdateConfFromInit; + } + } +} diff --git a/mods/TelegramAuth/Models/TelegramAuthConf.cs b/mods/TelegramAuth/Models/TelegramAuthConf.cs new file mode 100644 index 0000000..e02bec7 --- /dev/null +++ b/mods/TelegramAuth/Models/TelegramAuthConf.cs @@ -0,0 +1,38 @@ +using Shared.Models.AppConf; +using Shared.Models.Module; + +namespace TelegramAuth.Models +{ + public class TelegramAuthConf : ModuleBaseConf + { + public bool enable { get; set; } + + public string? data_dir { get; set; } + + public string legacy_import_path { get; set; } = ""; + + public bool enable_import { get; set; } = true; + + public bool enable_cleanup { get; set; } = true; + + public int max_active_devices_per_user { get; set; } + + public string mutations_api_secret { get; set; } = ""; + + public long[]? owner_telegram_ids { get; set; } + + public bool auto_provision_users { get; set; } + + public string auto_provision_role { get; set; } = "user"; + + public string auto_provision_lang { get; set; } = "ru"; + + public int auto_provision_expires_days { get; set; } + + public bool auto_provision_activate_immediately { get; set; } + + public int accsdb_sync_group_admin { get; set; } = 100; + + public int accsdb_sync_group_user { get; set; } + } +} diff --git a/mods/TelegramAuth/Models/TelegramAuthModels.cs b/mods/TelegramAuth/Models/TelegramAuthModels.cs new file mode 100644 index 0000000..158841f --- /dev/null +++ b/mods/TelegramAuth/Models/TelegramAuthModels.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace TelegramAuth.Models +{ + public class LegacyDeviceRecord + { + public string uid { get; set; } = ""; + public string? label { get; set; } + public string? bound_at { get; set; } + public string? last_seen { get; set; } + public string? last_ip { get; set; } + public string? fingerprint { get; set; } + } + + public class LegacyTokenRecord + { + public string token { get; set; } = ""; + public string telegram_id { get; set; } = ""; + public string tg_username { get; set; } = ""; + public DateTime? created_at { get; set; } + public DateTime? expires_at { get; set; } + public string approved_by { get; set; } = ""; + public List devices { get; set; } = new(); + } + + public class DeviceRecord + { + public string Uid { get; set; } = ""; + public string? Name { get; set; } + public DateTime LinkedAt { get; set; } = DateTime.UtcNow; + public DateTime? LastSeenAt { get; set; } + public bool Active { get; set; } = true; + public string Source { get; set; } = "import"; + } + + public class TelegramAccsProfile + { + [JsonProperty("group")] + public int? group { get; set; } + + [JsonProperty("IsPasswd")] + public bool? IsPasswd { get; set; } + + public bool? ban { get; set; } + + public string? ban_msg { get; set; } + + public string? comment { get; set; } + + [JsonProperty("params")] + public Dictionary? @params { get; set; } + + public List? ids { get; set; } + } + + public class TelegramUserRecord + { + public string TelegramId { get; set; } = ""; + public string TgUsername { get; set; } = ""; + public string Role { get; set; } = "user"; + public string Lang { get; set; } = "ru"; + public string? ApprovedBy { get; set; } + public DateTime? CreatedAt { get; set; } + public DateTime? ExpiresAt { get; set; } + + + public bool Disabled { get; set; } + + public bool RegistrationPending { get; set; } + + public List Devices { get; set; } = new(); + + [JsonProperty("accs")] + public TelegramAccsProfile? Accs { get; set; } + } + + public class AuthStatusResponse + { + public bool Authorized { get; set; } + public bool Pending { get; set; } + public bool RegistrationPending { get; set; } + public string? Message { get; set; } + public string? TelegramId { get; set; } + public string? Username { get; set; } + public string? Role { get; set; } + public DateTime? ExpiresAt { get; set; } + public int DeviceCount { get; set; } + } + + public class BindStartRequest + { + public string Uid { get; set; } = ""; + } + + public class DeviceSetNameRequest + { + [JsonProperty("uid", Required = Required.Always)] + public string Uid { get; set; } = ""; + [JsonProperty("name")] + public string? Name { get; set; } + } + + public class DeviceReactivateRequest + { + public string TelegramId { get; set; } = ""; + public string Uid { get; set; } = ""; + } + + public class DeviceUnbindRequest + { + public string TelegramId { get; set; } = ""; + public string Uid { get; set; } = ""; + } + + public class BindCompleteRequest + { + public string Uid { get; set; } = ""; + public string TelegramId { get; set; } = ""; + public string? Username { get; set; } + public string? DeviceName { get; set; } + } + + public class ImportResult + { + public int ImportedUsers { get; set; } + public int ImportedDevices { get; set; } + public int ImportedAdmins { get; set; } + public int ImportedLangs { get; set; } + } + + public class AdminSetUserDisabledRequest + { + public string TelegramId { get; set; } = ""; + public bool Disabled { get; set; } = true; + } + + public class AdminPendingDecisionRequest + { + public string TelegramId { get; set; } = ""; + [JsonProperty("approve")] + public bool Approve { get; set; } = true; + } + + public class BindDeviceOutcome + { + public bool NewUserProvisioned { get; set; } + public bool PendingAdminApproval { get; set; } + } +} diff --git a/mods/TelegramAuth/README.md b/mods/TelegramAuth/README.md new file mode 100644 index 0000000..47af8c8 --- /dev/null +++ b/mods/TelegramAuth/README.md @@ -0,0 +1,137 @@ +# TelegramAuth + +HTTP API и файловое хранилище для привязки **UID устройства Lampa** к учётке Telegram. Проект: **`Modules/Community/TelegramAuth/`**. Работает с [TelegramAuthBot](../TelegramAuthBot/README.md) и с клиентским плагином (см. [обзор Community](../README.md) — `deny.js` / `telegram_auth_gate.js`). + +--- + +## Включение + +- Убедитесь, что **`TelegramAuth`** (и при использовании бота — **`TelegramAuthBot`**) **нет** в **`BaseModule.SkipModules`** в `init.conf` (в шаблоне [`config/base.conf`](../../../config/base.conf) оба отключены по умолчанию). +- [`manifest.json`](manifest.json): **`"enable": true`**. +- `init.conf`: секция **`TelegramAuth`**. Пример: [`init.merge.example.json`](init.merge.example.json). + +--- + +## Назначение + +- Пользователи Telegram: `telegramId`, роль, срок доступа, устройства (UID). +- Статус по UID для UI: привязан / ожидание / истёк срок. +- Лимит **активных** устройств для роли `user` (`max_active_devices_per_user`; у `admin` — без лимита). +- Опционально: импорт legacy, очистка устройств. + +--- + +## Связь с accsdb + +Если **`TelegramAuth.enable`** = **`true`**: + +- В Core выставляется **`accsdb.enable`**. +- Активные привязанные UID синхронизируются в **корневой** **`users.json`** (рядом с `init.conf`) в формате **`AccsUser`**. Хост обычно подхватывает файл в течение ~1 с. + +Если **`enable`** = **`false`**: только хранилище и HTTP API, без accsdb и без подмешивания WAF из этой секции. + +**Когда UID попадает в корневой `users.json`:** пользователь **не** в `RegistrationPending`, **не** `Disabled`, устройство **активно**. Иначе для затронутых UID выполняется **`RemoveUid`**. При нехватке слотов под новое устройство самое старое активное переводится в неактивное и его UID снимается с accsdb. + +Поле **`accs`** в записи пользователя в **`data_dir`/users.json** задаёт группу, бан, `params` (в т.ч. `telegram_id`) и т.д.; срок в accsdb берётся из **`ExpiresAt`** или **`accsdb.shared_daytime`** / 365 дней. Подробная логика: [`Services/AccsdbUidSync.cs`](Services/AccsdbUidSync.cs). + +**Регистрация:** при выключенном `auto_provision_users` запись с таким `telegramId` уже должна быть в **`data_dir`/users.json`. При включённом — может создаваться автоматически (см. таблицу конфига). Ожидание модерации: **`RegistrationPending`** + при создании часто **`Disabled`**. Подтверждение: `POST /tg/auth/admin/user/pending` или бот. + +**`owner_telegram_ids`:** при старте модуля создаются/обновляются как **admin** в **`data_dir`/users.json**. + +Ограничение **чатов** для админ-команд бота задаётся в **`TelegramAuthBot.admin_chat_ids`**, не здесь. + +--- + +## Конфигурация + +| Поле | Описание | +|------|----------| +| `enable` | `true` — accsdb + синхронизация UID + применение **`limit_map`** к WAF. `false` (по умолчанию) — только API/хранилище. | +| `data_dir` | Каталог данных (по умолчанию `database/tgauth`). | +| `legacy_import_path` | База для `POST /tg/auth/import`. | +| `enable_import` / `enable_cleanup` | Разрешить импорт / `POST /tg/auth/devices/cleanup`. | +| `max_active_devices_per_user` | Лимит активных устройств для `user`; `0` → **5**. Для `admin` не применяется. | +| `mutations_api_secret` | Заголовок **`X-TelegramAuth-Mutations-Secret`** (совпадает с ботом). Пусто → `bind/complete` без заголовка; мутации админа из бота без cookie — невозможны. | +| `owner_telegram_ids` | Владельцы → admin при старте. | +| `auto_provision_users` | Создавать пользователя при bind неизвестного `telegramId`. | +| `auto_provision_role` | Роль новой записи (`admin` через auto-provision недоступен → `user`). | +| `auto_provision_lang` | Язык по умолчанию. | +| `auto_provision_expires_days` | Срок в днях; `0` — без срока. | +| `auto_provision_activate_immediately` | `false` — pending до решения админа; `true` — сразу активен. | +| `limit_map` | Правила WAF (тот же формат, что **`WAF.limit_map`** в Core). | +| `accsdb_sync_group_admin` / `accsdb_sync_group_user` | Значение **`group`** в accsdb, если не задано в **`accs.group`** (по умолчанию 100 / 0). | + +### WAF и `limit_map` + +При **`TelegramAuth.enable`** записи из **`TelegramAuth.limit_map`** переносятся в **`CoreInit.conf.WAF.limit_map`**. Перед добавлением из глобального списка удаляются правила с тем же **`pattern`** (точное строковое совпадение, `Ordinal`). Core выбирает **первое** подходящее по пути правило; иначе — глобальный **`WAF.limit_req`**. + +Запрос может **не попадать** под лимит, если для него выставлены **`IsLocalRequest`** / **`IsAnonymousRequest`** — см. порядок middleware в хосте. + +Шаблон по умолчанию в примере: `^/tg/auth` с лимитом запросов с IP. Переопределение: [`init.merge.example.json`](init.merge.example.json). + +--- + +## Хранилище (`data_dir`) + +| Файл | Содержимое | +|------|------------| +| `users.json` | Пользователи Telegram, устройства, опционально **`accs`**. | +| `admins.json` | Служебно (legacy-импорт). | +| `user_langs.json` | `telegramId → язык`. | + +--- + +## HTTP API (префикс `/tg/auth`) + +Маршруты анонимны в смысле cookie-сессии Lampac; админские мутации требуют cookie **`accspasswd`** (root) **или** заголовок **`X-TelegramAuth-Mutations-Secret`** при **непустом** `mutations_api_secret`. Если секрет пуст — заголовок не принимается, для мутаций остаётся только cookie. + +**`POST /tg/auth/bind/complete`:** секрет обязателен только если `mutations_api_secret` не пустой. + +### Чтение / статус + +- `GET /tg/auth/status?uid=` — авторизован ли UID, срок, роль, число устройств. +- `GET /tg/auth/me?uid=` — полная запись владельца UID (404 если нет). + +### Пользователь по Telegram + +- `GET /tg/auth/user/by-telegram?telegramId=` — сводка для бота. +- `GET /tg/auth/devices?telegramId=` — список устройств. + +### Привязка и устройства + +- `POST /tg/auth/bind/start` — `{ "uid" }`. +- `POST /tg/auth/bind/complete` — `{ "uid", "telegramId", "username?", "deviceName?" }`. +- `POST /tg/auth/device/name` — `{ "uid", "name?" }` (плагин Lampa после успешного статуса). +- `POST /tg/auth/device/unbind` — `{ "telegramId", "uid" }` (без секрета мутаций). +- `POST /tg/auth/device/reactivate` — `{ "telegramId", "uid" }` (403 если pending/disabled). + +### Административные + +- `GET /tg/auth/admin/users` +- `GET /tg/auth/admin/user?telegramId=` +- `POST /tg/auth/admin/user/patch` — патч профиля и **`accs`**, см. код/бот `/setuser`. +- `POST /tg/auth/admin/user/disabled` — `{ "telegramId", "disabled" }` (admin нельзя отключить). +- `POST /tg/auth/admin/user/pending` — `{ "telegramId", "approve" }` (отклонение удаляет пользователя из `data_dir` и снимает UID с accsdb). +- `POST /tg/auth/import` +- `POST /tg/auth/devices/cleanup` + +--- + +## Безопасность + +- Не коммитьте **`mutations_api_secret`**. В проде лучше ненулевой секрет. +- Расширяйте **`limit_map`** при необходимости. +- GET с `uid` / `telegramId` раскрывают факт привязки — при жёсткой модели угроз ограничивайте сеть/прокси. + +--- + +## Связь с ботом и клиентом + +- Бот ходит на **`TelegramAuthBot.lampac_base_url`** + пути `tg/auth/...`. Секрет должен совпадать с **`TelegramAuth.mutations_api_secret`**. +- Клиент Lampa: [Community README — плагины](../README.md). + +--- + +## Legacy-импорт + +Каталог **`legacy_import_path`**: минимум **`tokens.json`**; опционально `admin_ids.json`, `user_langs.json`. Форматы — `TelegramAuthStore.ImportFromLegacy`. Запись с `approved_by` = **`registration-pending`** → pending/disabled как при auto-provision без немедленной активации. diff --git a/mods/TelegramAuth/Services/AccsdbUidSync.cs b/mods/TelegramAuth/Services/AccsdbUidSync.cs new file mode 100644 index 0000000..f21c4df --- /dev/null +++ b/mods/TelegramAuth/Services/AccsdbUidSync.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Shared.Models.Base; +using TelegramAuth.Models; + +namespace TelegramAuth.Services +{ + internal static class AccsdbUidSync + { + const string UsersFileName = "users.json"; + static readonly object FileLock = new object(); + + static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings + { + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Ignore + }; + + public static void UpsertTelegramDevice(string deviceUid, TelegramUserRecord tgUser, DateTime expiresLocal, int groupAdmin, int groupUser) + { + if (string.IsNullOrWhiteSpace(deviceUid) || tgUser == null) + return; + + var row = BuildAccsUserForDevice(deviceUid.Trim(), tgUser, expiresLocal, groupAdmin, groupUser); + lock (FileLock) + { + EnsureFileExistsUnlocked(); + + var list = ReadListUnlocked(); + var key = row.id; + var found = list.FirstOrDefault(u => + (u.id != null && string.Equals(u.id, key, StringComparison.OrdinalIgnoreCase)) || + (u.ids != null && u.ids.Any(id => string.Equals(id, key, StringComparison.OrdinalIgnoreCase)))); + + if (found != null) + { + found.id = row.id; + found.ids = row.ids ?? new List(); + found.IsPasswd = row.IsPasswd; + found.expires = row.expires; + found.group = row.group; + found.ban = row.ban; + found.ban_msg = row.ban_msg; + found.comment = row.comment; + found.@params = row.@params; + } + else + list.Add(row); + + WriteListUnlocked(list); + } + } + + static AccsUser BuildAccsUserForDevice(string deviceUid, TelegramUserRecord tg, DateTime expiresLocal, int groupAdmin, int groupUser) + { + var accs = tg.Accs; + var group = accs?.group ?? (string.Equals(tg.Role, "admin", StringComparison.OrdinalIgnoreCase) ? groupAdmin : groupUser); + var isPasswd = accs?.IsPasswd ?? false; + var banned = accs?.ban == true; + + var ids = accs?.ids != null && accs.ids.Count > 0 + ? accs.ids.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList() + : new List(); + + var paramDict = new Dictionary(StringComparer.Ordinal); + if (accs?.@params != null) + { + foreach (var kv in accs.@params) + paramDict[kv.Key] = kv.Value; + } + + paramDict["telegram_id"] = tg.TelegramId; + if (!string.IsNullOrWhiteSpace(tg.TgUsername)) + paramDict["telegram_username"] = tg.TgUsername.Trim(); + + string comment; + if (!string.IsNullOrWhiteSpace(accs?.comment)) + comment = accs.comment.Trim(); + else if (!string.IsNullOrWhiteSpace(tg.TgUsername)) + comment = $"telegram:{tg.TelegramId} @{tg.TgUsername.Trim()}"; + else + comment = $"telegram:{tg.TelegramId}"; + + return new AccsUser + { + id = deviceUid, + ids = ids, + expires = expiresLocal, + group = group, + ban = banned, + ban_msg = string.IsNullOrWhiteSpace(accs?.ban_msg) ? null : accs.ban_msg.Trim(), + comment = comment, + IsPasswd = isPasswd, + @params = paramDict + }; + } + + public static void RemoveUid(string deviceUid) + { + if (string.IsNullOrWhiteSpace(deviceUid)) + return; + + var key = deviceUid.Trim(); + lock (FileLock) + { + if (!File.Exists(Path.GetFullPath(UsersFileName))) + return; + + var list = ReadListUnlocked(); + foreach (var u in list) + { + if (u.ids != null && u.ids.Count > 0) + u.ids = u.ids.Where(id => !string.Equals(id, key, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + list = list + .Where(u => u.id == null || !string.Equals(u.id, key, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + WriteListUnlocked(list); + } + } + + static void EnsureFileExistsUnlocked() + { + var full = Path.GetFullPath(UsersFileName); + var dir = Path.GetDirectoryName(full); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + if (!File.Exists(full)) + File.WriteAllText(full, "[]" + Environment.NewLine); + } + + static List ReadListUnlocked() + { + try + { + var full = Path.GetFullPath(UsersFileName); + if (!File.Exists(full)) + return new List(); + + var txt = File.ReadAllText(full); + if (string.IsNullOrWhiteSpace(txt)) + return new List(); + + return JsonConvert.DeserializeObject>(txt) ?? new List(); + } + catch + { + return new List(); + } + } + + static void WriteListUnlocked(List list) + { + var full = Path.GetFullPath(UsersFileName); + var dir = Path.GetDirectoryName(full); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + File.WriteAllText(full, JsonConvert.SerializeObject(list, JsonSettings)); + } + } +} diff --git a/mods/TelegramAuth/Services/TelegramAuthBindException.cs b/mods/TelegramAuth/Services/TelegramAuthBindException.cs new file mode 100644 index 0000000..7b28954 --- /dev/null +++ b/mods/TelegramAuth/Services/TelegramAuthBindException.cs @@ -0,0 +1,21 @@ +using System; + +namespace TelegramAuth.Services +{ + public enum TelegramAuthBindFailureKind + { + UserNotFound, + UserDisabled + } + + public sealed class TelegramAuthBindException : Exception + { + public TelegramAuthBindFailureKind FailureKind { get; } + + public TelegramAuthBindException(TelegramAuthBindFailureKind failureKind, string message) + : base(message) + { + FailureKind = failureKind; + } + } +} diff --git a/mods/TelegramAuth/Services/TelegramAuthStore.Operations.cs b/mods/TelegramAuth/Services/TelegramAuthStore.Operations.cs new file mode 100644 index 0000000..9a7e733 --- /dev/null +++ b/mods/TelegramAuth/Services/TelegramAuthStore.Operations.cs @@ -0,0 +1,696 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using TelegramAuth.Models; + +namespace TelegramAuth.Services +{ + public partial class TelegramAuthStore + { + public enum SetUserDisabledOutcome + { + Ok, + NotFound, + CannotDisableAdmin + } + + public SetUserDisabledOutcome TrySetUserDisabled(string telegramId, bool disabled) + { + if (string.IsNullOrWhiteSpace(telegramId)) + return SetUserDisabledOutcome.NotFound; + + var users = GetUsers(); + var user = users.FirstOrDefault(u => string.Equals(u.TelegramId, telegramId, StringComparison.Ordinal)); + if (user == null) + return SetUserDisabledOutcome.NotFound; + + if (disabled && string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase)) + return SetUserDisabledOutcome.CannotDisableAdmin; + + user.Disabled = disabled; + if (disabled) + { + user.RegistrationPending = false; + if (ShouldSyncAccsdb() && user.Devices != null) + { + foreach (var d in user.Devices) + { + if (!string.IsNullOrWhiteSpace(d.Uid)) + AccsdbUidSync.RemoveUid(d.Uid); + } + } + + if (user.Devices != null) + { + foreach (var d in user.Devices) + d.Active = false; + } + } + else + user.RegistrationPending = false; + + SaveUsers(users); + + if (!disabled && ShouldSyncAccsdb() && IsActive(user)) + { + foreach (var d in user.Devices ?? new List()) + { + if (d.Active && !string.IsNullOrWhiteSpace(d.Uid)) + SyncDeviceToAccsdb(user, d.Uid); + } + } + + return SetUserDisabledOutcome.Ok; + } + + public enum PendingDecisionOutcome + { + Ok, + NotFound, + NotPending, + CannotRejectAdmin + } + + public PendingDecisionOutcome TryApproveRegistrationPending(string telegramId) + { + var tid = telegramId?.Trim(); + if (string.IsNullOrEmpty(tid)) + return PendingDecisionOutcome.NotFound; + + var users = GetUsers(); + var user = users.FirstOrDefault(u => string.Equals(u.TelegramId, tid, StringComparison.Ordinal)); + if (user == null) + return PendingDecisionOutcome.NotFound; + if (!IsRegistrationPending(user)) + return PendingDecisionOutcome.NotPending; + + user.RegistrationPending = false; + user.Disabled = false; + user.ApprovedBy = "admin-approved"; + SaveUsers(users); + if (ShouldSyncAccsdb() && IsActive(user)) + { + foreach (var d in user.Devices ?? new List()) + { + if (d.Active && !string.IsNullOrWhiteSpace(d.Uid)) + SyncDeviceToAccsdb(user, d.Uid); + } + } + + return PendingDecisionOutcome.Ok; + } + + public PendingDecisionOutcome TryRejectRegistrationPending(string telegramId) + { + var tid = telegramId?.Trim(); + if (string.IsNullOrEmpty(tid)) + return PendingDecisionOutcome.NotFound; + + var users = GetUsers(); + var user = users.FirstOrDefault(u => string.Equals(u.TelegramId, tid, StringComparison.Ordinal)); + if (user == null) + return PendingDecisionOutcome.NotFound; + if (!IsRegistrationPending(user)) + return PendingDecisionOutcome.NotPending; + if (string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase)) + return PendingDecisionOutcome.CannotRejectAdmin; + + if (ShouldSyncAccsdb()) + { + foreach (var d in user.Devices ?? new List()) + { + if (!string.IsNullOrWhiteSpace(d.Uid)) + AccsdbUidSync.RemoveUid(d.Uid); + } + } + + users.Remove(user); + SaveUsers(users); + return PendingDecisionOutcome.Ok; + } + + internal const int DeviceDisplayNameMaxLen = 500; + + public enum SetDeviceDisplayNameOutcome + { + Ok, + InvalidUid, + NotFoundOrInactive + } + + public enum ReactivateDeviceOutcome + { + Ok, + UserNotFound, + UserDisabled, + DeviceNotFound + } + + public enum UnbindDeviceOutcome + { + Ok, + UserNotFound, + DeviceNotFound + } + + public UnbindDeviceOutcome TryUnbindDevice(string telegramId, string uid) + { + var tid = telegramId?.Trim(); + var u = uid?.Trim(); + if (string.IsNullOrEmpty(tid) || string.IsNullOrEmpty(u)) + return UnbindDeviceOutcome.DeviceNotFound; + + var users = GetUsers(); + var user = users.FirstOrDefault(x => string.Equals(x.TelegramId, tid, StringComparison.Ordinal)); + if (user == null) + return UnbindDeviceOutcome.UserNotFound; + + var device = user.Devices?.FirstOrDefault(d => string.Equals(d.Uid, u, StringComparison.OrdinalIgnoreCase)); + if (device == null) + return UnbindDeviceOutcome.DeviceNotFound; + + device.Active = false; + SaveUsers(users); + if (ShouldSyncAccsdb()) + AccsdbUidSync.RemoveUid(u); + return UnbindDeviceOutcome.Ok; + } + + public ReactivateDeviceOutcome TryReactivateDevice(string telegramId, string uid) + { + var tid = telegramId?.Trim(); + var u = uid?.Trim(); + if (string.IsNullOrEmpty(tid) || string.IsNullOrEmpty(u)) + return ReactivateDeviceOutcome.DeviceNotFound; + + var users = GetUsers(); + var user = users.FirstOrDefault(x => string.Equals(x.TelegramId, tid, StringComparison.Ordinal)); + if (user == null) + return ReactivateDeviceOutcome.UserNotFound; + if (IsRegistrationPending(user) || user.Disabled) + return ReactivateDeviceOutcome.UserDisabled; + + var device = user.Devices?.FirstOrDefault(d => string.Equals(d.Uid, u, StringComparison.OrdinalIgnoreCase)); + if (device == null) + return ReactivateDeviceOutcome.DeviceNotFound; + if (device.Active) + return ReactivateDeviceOutcome.Ok; + + CleanupInactiveDevices(users, 90); + + BumpOldestActiveDeviceIfAtDeviceLimit(user); + + device.Active = true; + device.LastSeenAt = DateTime.UtcNow; + SaveUsers(users); + if (ShouldSyncAccsdb() && IsActive(user) && device.Active) + SyncDeviceToAccsdb(user, u); + return ReactivateDeviceOutcome.Ok; + } + + public SetDeviceDisplayNameOutcome TrySetActiveDeviceDisplayName(string uid, string? name) + { + var u = uid?.Trim(); + if (string.IsNullOrEmpty(u)) + return SetDeviceDisplayNameOutcome.InvalidUid; + + string? resolved = null; + if (!string.IsNullOrWhiteSpace(name)) + { + resolved = name.Trim(); + if (resolved.Length > DeviceDisplayNameMaxLen) + resolved = resolved.Substring(0, DeviceDisplayNameMaxLen); + } + + var users = GetUsers(); + foreach (var user in users) + { + if (!IsActive(user)) + continue; + + var dev = user.Devices?.FirstOrDefault(d => d.Active && string.Equals(d.Uid, u, StringComparison.OrdinalIgnoreCase)); + if (dev == null) + continue; + + dev.Name = resolved; + SaveUsers(users); + return SetDeviceDisplayNameOutcome.Ok; + } + + return SetDeviceDisplayNameOutcome.NotFoundOrInactive; + } + + public int GetMaxDevices(TelegramUserRecord user) + { + if (user != null && string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase)) + return AdminUnlimitedDevices; + + int max = _conf.max_active_devices_per_user; + return max > 0 ? max : MaxActiveDevicesPerUser; + } + + public const string BindUserNotFoundMessage = "user not found"; + public const string BindUserDisabledMessage = "user disabled"; + + public BindDeviceOutcome BindDevice(string telegramId, string uid, string? telegramUsername = null, string? deviceDisplayName = null, string source = "manual") + { + var outcome = new BindDeviceOutcome(); + var users = GetUsers(); + var user = users.FirstOrDefault(u => u.TelegramId == telegramId); + if (user == null) + { + if (!_conf.auto_provision_users) + throw new TelegramAuthBindException(TelegramAuthBindFailureKind.UserNotFound, BindUserNotFoundMessage); + + DateTime? expires = null; + var days = _conf.auto_provision_expires_days; + if (days > 0) + expires = DateTime.UtcNow.AddDays(days); + + var role = string.IsNullOrWhiteSpace(_conf.auto_provision_role) + ? "user" + : _conf.auto_provision_role.Trim(); + if (string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)) + role = "user"; + + var pendingApproval = !_conf.auto_provision_activate_immediately; + user = new TelegramUserRecord + { + TelegramId = telegramId, + TgUsername = telegramUsername?.Trim() ?? "", + Role = role, + Lang = string.IsNullOrWhiteSpace(_conf.auto_provision_lang) ? "ru" : _conf.auto_provision_lang.Trim(), + CreatedAt = DateTime.UtcNow, + ExpiresAt = expires, + ApprovedBy = pendingApproval ? RegistrationPendingApprovedByMarker : "auto-provision", + Disabled = pendingApproval, + RegistrationPending = pendingApproval, + Devices = new List() + }; + users.Add(user); + outcome.NewUserProvisioned = true; + outcome.PendingAdminApproval = pendingApproval; + } + else if (user.Disabled && !IsRegistrationPending(user)) + { + throw new TelegramAuthBindException(TelegramAuthBindFailureKind.UserDisabled, BindUserDisabledMessage); + } + else if (!string.IsNullOrWhiteSpace(telegramUsername)) + { + var trimmed = telegramUsername.Trim(); + if (!string.Equals(user.TgUsername, trimmed, StringComparison.Ordinal)) + user.TgUsername = trimmed; + } + + string? resolvedDeviceName = null; + if (!string.IsNullOrWhiteSpace(deviceDisplayName)) + { + var t = deviceDisplayName.Trim(); + resolvedDeviceName = t.Length > DeviceDisplayNameMaxLen ? t.Substring(0, DeviceDisplayNameMaxLen) : t; + } + + CleanupInactiveDevices(users, 90); + + var existing = user.Devices.FirstOrDefault(d => string.Equals(d.Uid, uid, StringComparison.OrdinalIgnoreCase)); + if (existing == null) + { + BumpOldestActiveDeviceIfAtDeviceLimit(user); + + user.Devices.Add(new DeviceRecord + { + Uid = uid, + Name = resolvedDeviceName, + LinkedAt = DateTime.UtcNow, + LastSeenAt = DateTime.UtcNow, + Active = true, + Source = source + }); + } + else + { + if (!existing.Active) + BumpOldestActiveDeviceIfAtDeviceLimit(user); + + existing.Active = true; + if (!string.IsNullOrWhiteSpace(resolvedDeviceName)) + existing.Name = resolvedDeviceName; + existing.LastSeenAt = DateTime.UtcNow; + } + + SaveUsers(users); + SyncAccsdbForBind(user, uid); + return outcome; + } + + public AuthStatusResponse GetStatus(string uid) + { + var user = FindByUid(uid); + if (user == null) + { + return new AuthStatusResponse + { + Authorized = false, + Pending = true, + Message = "Устройство не привязано. Нужна авторизация через Telegram." + }; + } + + var now = DateTime.UtcNow; + if (IsRegistrationPending(user)) + { + return new AuthStatusResponse + { + Authorized = false, + Pending = true, + RegistrationPending = true, + Message = "Аккаунт ожидает подтверждения администратора.", + TelegramId = user.TelegramId, + Username = user.TgUsername, + Role = user.Role, + ExpiresAt = user.ExpiresAt, + DeviceCount = user.Devices.Count(d => d.Active) + }; + } + + if (user.Disabled) + { + return new AuthStatusResponse + { + Authorized = false, + Pending = false, + RegistrationPending = false, + Message = "Доступ отключён администратором.", + TelegramId = user.TelegramId, + Username = user.TgUsername, + Role = user.Role, + ExpiresAt = user.ExpiresAt, + DeviceCount = user.Devices.Count(d => d.Active) + }; + } + + var expired = user.ExpiresAt.HasValue && user.ExpiresAt.Value.ToUniversalTime() < now; + return new AuthStatusResponse + { + Authorized = !expired, + Pending = false, + RegistrationPending = false, + Message = expired ? "Срок доступа истёк." : "OK", + TelegramId = user.TelegramId, + Username = user.TgUsername, + Role = user.Role, + ExpiresAt = user.ExpiresAt, + DeviceCount = user.Devices.Count(d => d.Active) + }; + } + + public int CleanupInactiveDevices(List? users = null, int olderThanDays = 90) + { + users ??= GetUsers(); + var threshold = DateTime.UtcNow.AddDays(-olderThanDays); + var removed = 0; + + foreach (var user in users) + { + if (user.Devices == null) + continue; + + var before = user.Devices.Count; + user.Devices = user.Devices + .Where(d => d.Active || ((d.LastSeenAt ?? d.LinkedAt) >= threshold)) + .ToList(); + + var maxDevices = GetMaxDevices(user); + if (maxDevices > 0) + { + var activeDevices = user.Devices.Where(d => d.Active).OrderByDescending(d => d.LastSeenAt ?? d.LinkedAt).ToList(); + if (activeDevices.Count > maxDevices) + { + foreach (var extra in activeDevices.Skip(maxDevices)) + { + extra.Active = false; + RemoveUidFromAccsdbIfSync(extra.Uid); + } + } + } + + removed += before - user.Devices.Count; + } + + SaveUsers(users); + return removed; + } + + public enum AdminPatchUserOutcome + { + Ok, + NotFound, + InvalidPayload + } + + public AdminPatchUserOutcome TryAdminPatchUser(string telegramId, JObject body, out string? errorDetail) + { + errorDetail = null; + if (string.IsNullOrWhiteSpace(telegramId)) + { + errorDetail = "telegramId is required"; + return AdminPatchUserOutcome.InvalidPayload; + } + + if (body == null) + { + errorDetail = "body is required"; + return AdminPatchUserOutcome.InvalidPayload; + } + + var users = GetUsers(); + var tid = telegramId.Trim(); + var user = users.FirstOrDefault(u => string.Equals(u.TelegramId, tid, StringComparison.Ordinal)); + if (user == null) + return AdminPatchUserOutcome.NotFound; + + if (body.ContainsKey("expiresAt")) + { + var t = body["expiresAt"]; + if (t == null || t.Type == JTokenType.Null) + user.ExpiresAt = null; + else if (t.Type == JTokenType.String && string.IsNullOrWhiteSpace(t.Value())) + user.ExpiresAt = null; + else + { + var s = t.Type == JTokenType.String ? t.Value() : t.ToString(); + if (!DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)) + { + errorDetail = "expiresAt: invalid date"; + return AdminPatchUserOutcome.InvalidPayload; + } + + user.ExpiresAt = dt; + } + } + + if (body.ContainsKey("lang")) + { + var s = body["lang"]?.Value()?.Trim(); + if (!string.IsNullOrEmpty(s)) + user.Lang = s; + } + + if (body.ContainsKey("role")) + { + var r = body["role"]?.Value()?.Trim(); + if (string.IsNullOrEmpty(r)) + { + errorDetail = "role: empty"; + return AdminPatchUserOutcome.InvalidPayload; + } + + if (!string.Equals(r, "admin", StringComparison.OrdinalIgnoreCase) + && !string.Equals(r, "user", StringComparison.OrdinalIgnoreCase)) + { + errorDetail = "role: use user or admin"; + return AdminPatchUserOutcome.InvalidPayload; + } + + user.Role = string.Equals(r, "admin", StringComparison.OrdinalIgnoreCase) ? "admin" : "user"; + } + + if (body["accs"] is JObject accsJo) + { + user.Accs ??= new TelegramAccsProfile(); + MergeAccsPatch(user.Accs, accsJo, out var accsErr); + if (accsErr != null) + { + errorDetail = accsErr; + return AdminPatchUserOutcome.InvalidPayload; + } + } + + if (body["accsRemove"] is JArray rem) + { + user.Accs ??= new TelegramAccsProfile(); + foreach (var x in rem) + { + var key = x.Value()?.Trim(); + if (!string.IsNullOrEmpty(key)) + ApplyAccsRemove(user.Accs, key); + } + } + + if (body["paramsRemove"] is JArray pr) + { + user.Accs ??= new TelegramAccsProfile(); + user.Accs.@params ??= new Dictionary(StringComparer.Ordinal); + foreach (var x in pr) + { + var k = x.Value(); + if (!string.IsNullOrEmpty(k)) + user.Accs.@params.Remove(k); + } + } + + SaveUsers(users); + ResyncUserDevicesToAccsdb(user); + return AdminPatchUserOutcome.Ok; + } + + static void MergeAccsPatch(TelegramAccsProfile accs, JObject jo, out string? error) + { + error = null; + if (jo.ContainsKey("group")) + { + var t = jo["group"]; + if (t == null || t.Type == JTokenType.Null) + accs.group = null; + else if (t.Type == JTokenType.Integer || t.Type == JTokenType.Float) + accs.group = (int)t.Value(); + else + { + error = "accs.group: expected integer or null"; + return; + } + } + + if (jo.ContainsKey("IsPasswd")) + { + var t = jo["IsPasswd"]; + if (t == null || t.Type == JTokenType.Null) + accs.IsPasswd = null; + else if (t.Type != JTokenType.Boolean) + { + error = "accs.IsPasswd: expected boolean or null"; + return; + } + else + accs.IsPasswd = t.Value(); + } + + if (jo.ContainsKey("ban")) + { + var t = jo["ban"]; + if (t == null || t.Type == JTokenType.Null) + accs.ban = null; + else if (t.Type != JTokenType.Boolean) + { + error = "accs.ban: expected boolean or null"; + return; + } + else + accs.ban = t.Value(); + } + + if (jo.ContainsKey("ban_msg")) + { + var t = jo["ban_msg"]; + if (t == null || t.Type == JTokenType.Null) + accs.ban_msg = null; + else + { + var s = t.Value()?.Trim(); + accs.ban_msg = string.IsNullOrEmpty(s) ? null : s; + } + } + + if (jo.ContainsKey("comment")) + { + var t = jo["comment"]; + if (t == null || t.Type == JTokenType.Null) + accs.comment = null; + else + { + var s = t.Value()?.Trim(); + accs.comment = string.IsNullOrEmpty(s) ? null : s; + } + } + + if (jo.ContainsKey("ids")) + { + var arr = jo["ids"] as JArray; + if (arr == null || arr.Count == 0) + accs.ids = null; + else + { + accs.ids = arr + .Select(x => x?.ToString()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s!.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } + + if (jo["params"] is JObject pjo) + { + accs.@params ??= new Dictionary(StringComparer.Ordinal); + foreach (var prop in pjo.Properties()) + { + if (prop.Value == null || prop.Value.Type == JTokenType.Null) + accs.@params.Remove(prop.Name); + else + accs.@params[prop.Name] = prop.Value.Type == JTokenType.String + ? (prop.Value.Value() ?? "") + : prop.Value.Type == JTokenType.Integer + ? prop.Value.Value() + : prop.Value.Type == JTokenType.Float + ? prop.Value.Value() + : prop.Value.Type == JTokenType.Boolean + ? prop.Value.Value() + : prop.Value.ToString(Formatting.None); + } + } + } + + static void ApplyAccsRemove(TelegramAccsProfile accs, string key) + { + switch (key.ToLowerInvariant()) + { + case "group": + accs.group = null; + break; + case "ispasswd": + case "is_passwd": + case "passwd": + accs.IsPasswd = null; + break; + case "ban": + accs.ban = null; + break; + case "ban_msg": + case "banmsg": + accs.ban_msg = null; + break; + case "comment": + accs.comment = null; + break; + case "ids": + accs.ids = null; + break; + case "params": + accs.@params = null; + break; + } + } + } +} diff --git a/mods/TelegramAuth/Services/TelegramAuthStore.cs b/mods/TelegramAuth/Services/TelegramAuthStore.cs new file mode 100644 index 0000000..5207f52 --- /dev/null +++ b/mods/TelegramAuth/Services/TelegramAuthStore.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Shared; +using TelegramAuth.Models; + +namespace TelegramAuth.Services +{ + public partial class TelegramAuthStore + { + readonly TelegramAuthConf _conf; + readonly string baseDir; + readonly string usersPath; + readonly string adminsPath; + readonly string langsPath; + public const int MaxActiveDevicesPerUser = 5; + public const int AdminUnlimitedDevices = -1; + + public TelegramAuthStore(TelegramAuthConf conf) + { + _conf = conf ?? new TelegramAuthConf(); + var rel = string.IsNullOrWhiteSpace(_conf.data_dir) + ? Path.Combine("database", "tgauth") + : _conf.data_dir.Trim().TrimStart('/', '\\'); + baseDir = Path.IsPathRooted(rel) + ? rel + : Path.Combine(AppContext.BaseDirectory, rel); + usersPath = Path.Combine(baseDir, "users.json"); + adminsPath = Path.Combine(baseDir, "admins.json"); + langsPath = Path.Combine(baseDir, "user_langs.json"); + } + + public void EnsureStorage() + { + Directory.CreateDirectory(baseDir); + if (!File.Exists(usersPath)) File.WriteAllText(usersPath, "[]"); + if (!File.Exists(adminsPath)) File.WriteAllText(adminsPath, "[]"); + if (!File.Exists(langsPath)) File.WriteAllText(langsPath, "{}"); + } + + public void EnsureOwnerUsersAtStartup() + { + var ids = _conf.owner_telegram_ids; + if (ids == null || ids.Length == 0) + return; + + var users = GetUsers(); + var changed = false; + var lang = string.IsNullOrWhiteSpace(_conf.auto_provision_lang) ? "ru" : _conf.auto_provision_lang.Trim(); + + foreach (var ownerNumericId in ids) + { + var telegramId = ownerNumericId.ToString(CultureInfo.InvariantCulture); + var user = users.FirstOrDefault(u => string.Equals(u.TelegramId, telegramId, StringComparison.Ordinal)); + if (user == null) + { + users.Add(new TelegramUserRecord + { + TelegramId = telegramId, + TgUsername = "", + Role = "admin", + Lang = lang, + CreatedAt = DateTime.UtcNow, + ApprovedBy = "owner-config", + Disabled = false, + Devices = new List() + }); + changed = true; + continue; + } + + if (!string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase)) + { + user.Role = "admin"; + changed = true; + } + + if (user.Disabled) + { + user.Disabled = false; + changed = true; + } + + if (string.IsNullOrWhiteSpace(user.ApprovedBy)) + { + user.ApprovedBy = "owner-config"; + changed = true; + } + } + + if (changed) + SaveUsers(users); + } + + public List GetUsers() + { + EnsureStorage(); + return JsonConvert.DeserializeObject>(File.ReadAllText(usersPath)) ?? new List(); + } + + public void SaveUsers(List users) + { + EnsureStorage(); + File.WriteAllText(usersPath, JsonConvert.SerializeObject(users, Formatting.Indented)); + } + + public Dictionary GetLangs() + { + EnsureStorage(); + return JsonConvert.DeserializeObject>(File.ReadAllText(langsPath)) ?? new Dictionary(); + } + + public TelegramUserRecord? FindByUid(string uid) + { + if (string.IsNullOrWhiteSpace(uid)) return null; + return GetUsers().FirstOrDefault(u => u.Devices.Any(d => d.Active && string.Equals(d.Uid, uid, StringComparison.OrdinalIgnoreCase))); + } + + public TelegramUserRecord? FindByTelegramId(string telegramId) + { + if (string.IsNullOrWhiteSpace(telegramId)) return null; + return GetUsers().FirstOrDefault(u => string.Equals(u.TelegramId, telegramId, StringComparison.Ordinal)); + } + + public static bool IsRegistrationPending(TelegramUserRecord? user) => user?.RegistrationPending == true; + + public bool IsActive(TelegramUserRecord user) + { + if (user == null) + return false; + + if (IsRegistrationPending(user)) + return false; + + if (user.Disabled) + return false; + + return !user.ExpiresAt.HasValue || user.ExpiresAt.Value.ToUniversalTime() >= DateTime.UtcNow; + } + + bool ShouldSyncAccsdb() => + _conf.enable && + CoreInit.conf != null && CoreInit.conf.accsdb.enable; + + void RemoveUidFromAccsdbIfSync(string deviceUid) + { + if (!ShouldSyncAccsdb() || string.IsNullOrWhiteSpace(deviceUid)) + return; + AccsdbUidSync.RemoveUid(deviceUid.Trim()); + } + + void BumpOldestActiveDeviceIfAtDeviceLimit(TelegramUserRecord user) + { + var maxDevices = GetMaxDevices(user); + if (maxDevices <= 0 || user.Devices == null) + return; + + var activeDevices = user.Devices.Where(d => d.Active).OrderByDescending(d => d.LastSeenAt ?? d.LinkedAt).ToList(); + if (activeDevices.Count < maxDevices) + return; + + var oldestActive = activeDevices.Last(); + oldestActive.Active = false; + RemoveUidFromAccsdbIfSync(oldestActive.Uid); + } + + DateTime ResolveAccsdbExpiresLocal(TelegramUserRecord user) + { + if (user.ExpiresAt.HasValue) + { + var v = user.ExpiresAt.Value; + return v.Kind == DateTimeKind.Utc ? v.ToLocalTime() : v; + } + + var sd = CoreInit.conf?.accsdb?.shared_daytime ?? 0; + var days = sd > 0 ? Math.Max(1, sd) : 365; + return DateTime.Now.AddDays(days); + } + + void SyncDeviceToAccsdb(TelegramUserRecord user, string deviceUid) + { + if (string.IsNullOrWhiteSpace(deviceUid) || user == null) + return; + + if (!ShouldSyncAccsdb()) + return; + + var u = deviceUid.Trim(); + + if (user.Disabled || IsRegistrationPending(user)) + { + AccsdbUidSync.RemoveUid(u); + return; + } + + AccsdbUidSync.UpsertTelegramDevice( + u, + user, + ResolveAccsdbExpiresLocal(user), + _conf.accsdb_sync_group_admin, + _conf.accsdb_sync_group_user); + } + + public void ResyncUserDevicesToAccsdb(TelegramUserRecord user) + { + if (user == null || !ShouldSyncAccsdb()) + return; + + if (user.Disabled || IsRegistrationPending(user)) + { + foreach (var d in user.Devices ?? Enumerable.Empty()) + { + if (!string.IsNullOrWhiteSpace(d.Uid)) + AccsdbUidSync.RemoveUid(d.Uid.Trim()); + } + + return; + } + + foreach (var d in user.Devices ?? Enumerable.Empty()) + { + if (d.Active && !string.IsNullOrWhiteSpace(d.Uid)) + SyncDeviceToAccsdb(user, d.Uid.Trim()); + } + } + + void SyncAccsdbForBind(TelegramUserRecord user, string? deviceUid) + { + if (!ShouldSyncAccsdb() || string.IsNullOrWhiteSpace(deviceUid)) + return; + + var u = deviceUid.Trim(); + + if (user.Disabled || IsRegistrationPending(user)) + { + AccsdbUidSync.RemoveUid(u); + return; + } + + if (!IsActive(user)) + return; + + var dev = user.Devices?.FirstOrDefault(d => string.Equals(d.Uid, u, StringComparison.OrdinalIgnoreCase)); + if (dev == null || !dev.Active) + return; + + SyncDeviceToAccsdb(user, u); + } + + const string RegistrationPendingApprovedByMarker = "registration-pending"; + + static bool LegacyApprovedByIsRegistrationPending(string? approvedBy) + { + if (string.IsNullOrWhiteSpace(approvedBy)) + return false; + return string.Equals(approvedBy.Trim(), RegistrationPendingApprovedByMarker, StringComparison.OrdinalIgnoreCase); + } + + public ImportResult ImportFromLegacy(string legacyBasePath) + { + EnsureStorage(); + + var legacyTokensPath = Path.Combine(legacyBasePath, "tokens.json"); + var legacyAdminsPath = Path.Combine(legacyBasePath, "admin_ids.json"); + var legacyLangsPath = Path.Combine(legacyBasePath, "user_langs.json"); + + var users = new Dictionary(StringComparer.Ordinal); + var langs = new Dictionary(StringComparer.Ordinal); + var result = new ImportResult(); + + if (File.Exists(legacyLangsPath)) + { + langs = JsonConvert.DeserializeObject>(File.ReadAllText(legacyLangsPath)) + ?? new Dictionary(StringComparer.Ordinal); + result.ImportedLangs = langs.Count; + } + + var adminIds = new HashSet(StringComparer.Ordinal); + if (File.Exists(legacyAdminsPath)) + { + var rawAdmins = JsonConvert.DeserializeObject>>(File.ReadAllText(legacyAdminsPath)) + ?? new List>(); + + foreach (var row in rawAdmins) + { + if (row.TryGetValue("telegram_id", out var v) && v != null) + adminIds.Add(v.ToString() ?? ""); + } + result.ImportedAdmins = adminIds.Count; + } + + if (File.Exists(legacyTokensPath)) + { + var raw = JsonConvert.DeserializeObject>(File.ReadAllText(legacyTokensPath)) + ?? new List(); + + foreach (var item in raw) + { + if (string.IsNullOrWhiteSpace(item.telegram_id)) + continue; + + if (!users.TryGetValue(item.telegram_id, out var user)) + { + var pending = LegacyApprovedByIsRegistrationPending(item.approved_by); + var isAdmin = adminIds.Contains(item.telegram_id); + if (isAdmin) + pending = false; + + var approvedBy = string.IsNullOrWhiteSpace(item.approved_by) + ? "legacy-import" + : item.approved_by.Trim(); + + user = new TelegramUserRecord + { + TelegramId = item.telegram_id, + TgUsername = item.tg_username ?? "", + ApprovedBy = approvedBy, + CreatedAt = item.created_at, + ExpiresAt = item.expires_at, + Lang = langs.TryGetValue(item.telegram_id, out var lang) ? lang : "ru", + Role = isAdmin ? "admin" : "user", + Disabled = pending, + RegistrationPending = pending, + Devices = new List() + }; + users[item.telegram_id] = user; + } + + foreach (var device in item.devices ?? new List()) + { + if (device == null || string.IsNullOrWhiteSpace(device.uid)) + continue; + + if (user.Devices.Any(d => string.Equals(d.Uid, device.uid, StringComparison.OrdinalIgnoreCase))) + continue; + + DateTime linkedAt = item.created_at ?? DateTime.UtcNow; + if (!string.IsNullOrWhiteSpace(device.bound_at)) + DateTime.TryParse(device.bound_at, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out linkedAt); + + DateTime? lastSeenAt = null; + if (!string.IsNullOrWhiteSpace(device.last_seen) && DateTime.TryParse(device.last_seen, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsedLastSeen)) + lastSeenAt = parsedLastSeen; + + user.Devices.Add(new DeviceRecord + { + Uid = device.uid, + Name = device.label, + LinkedAt = linkedAt, + LastSeenAt = lastSeenAt, + Active = true, + Source = "legacy-import" + }); + result.ImportedDevices++; + } + } + + result.ImportedUsers = users.Count; + } + + SaveUsers(users.Values.OrderBy(u => u.TelegramId, StringComparer.Ordinal).ToList()); + File.WriteAllText(langsPath, JsonConvert.SerializeObject(langs, Formatting.Indented)); + File.WriteAllText(adminsPath, JsonConvert.SerializeObject(adminIds.OrderBy(x => x, StringComparer.Ordinal).ToList(), Formatting.Indented)); + + return result; + } + } +} diff --git a/mods/TelegramAuth/TelegramAuth.csproj b/mods/TelegramAuth/TelegramAuth.csproj new file mode 100644 index 0000000..86637c1 --- /dev/null +++ b/mods/TelegramAuth/TelegramAuth.csproj @@ -0,0 +1,13 @@ + + + net10.0 + library + true + TelegramAuth + enable + + + + + + diff --git a/mods/TelegramAuth/init.merge.example.json b/mods/TelegramAuth/init.merge.example.json new file mode 100644 index 0000000..8a63094 --- /dev/null +++ b/mods/TelegramAuth/init.merge.example.json @@ -0,0 +1,28 @@ +{ + "TelegramAuth": { + "enable": true, + "data_dir": "database/tgauth", + "legacy_import_path": "database/legacy_tgauth", + "enable_import": true, + "enable_cleanup": true, + "mutations_api_secret": "GENERATE_A_LONG_RANDOM_SECRET", + "owner_telegram_ids": [123456789], + "auto_provision_users": false, + "auto_provision_role": "user", + "auto_provision_lang": "ru", + "auto_provision_expires_days": 0, + "auto_provision_activate_immediately": false, + "accsdb_sync_group_admin": 100, + "accsdb_sync_group_user": 0, + "max_active_devices_per_user": 5, + "limit_map": [ + { + "pattern": "^/tg/auth", + "map": { + "limit": 25, + "second": 1 + } + } + ] + } +} diff --git a/mods/TelegramAuth/manifest.json b/mods/TelegramAuth/manifest.json new file mode 100644 index 0000000..30a6969 --- /dev/null +++ b/mods/TelegramAuth/manifest.json @@ -0,0 +1,10 @@ +{ + "enable": true, + "dynamic": true, + "tree": [ + "Controllers", + "Models", + "Services", + "ModInit.cs" + ] +} \ No newline at end of file diff --git a/mods/TelegramAuthBot/GlobalUsings.cs b/mods/TelegramAuthBot/GlobalUsings.cs new file mode 100644 index 0000000..d2fe4b5 --- /dev/null +++ b/mods/TelegramAuthBot/GlobalUsings.cs @@ -0,0 +1,16 @@ +global using System; +global using System.Globalization; +global using System.Text; +global using System.Collections.Generic; +global using System.Linq; +global using System.Text.RegularExpressions; +global using System.Threading; +global using System.Threading.Tasks; +global using Microsoft.Extensions.Hosting; +global using Newtonsoft.Json.Linq; +global using Serilog; +global using Telegram.Bot; +global using Telegram.Bot.Types; +global using Telegram.Bot.Types.Enums; +global using Telegram.Bot.Types.ReplyMarkups; +global using TelegramAuthBot.Models; diff --git a/mods/TelegramAuthBot/ModInit.cs b/mods/TelegramAuthBot/ModInit.cs new file mode 100644 index 0000000..35769bd --- /dev/null +++ b/mods/TelegramAuthBot/ModInit.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.DependencyInjection; +using Shared.Models.Events; +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; +using Shared.Services; +using TelegramAuthBot.Models; +using TelegramAuthBot.Services; + +namespace TelegramAuthBot +{ + public class ModInit : IModuleLoaded, IModuleConfigure + { + public static TelegramAuthBotConf conf = new(); + + public void Configure(ConfigureModel app) + { + SyncConfFromInit(); + app.services.AddHostedService(); + } + + public void Loaded(InitspaceModel initspace) + { + SyncConfFromInit(); + EventListener.UpdateInitFile += SyncConfFromInit; + } + + public void Dispose() + { + EventListener.UpdateInitFile -= SyncConfFromInit; + } + + static void SyncConfFromInit() + { + conf = ModuleInvoke.Init("TelegramAuthBot", DefaultConfTemplate()); + + if (conf.enable && string.IsNullOrWhiteSpace(conf.bot_token)) + { + var msg = "TelegramAuthBot: enable=true, но bot_token пустой — проверьте секцию TelegramAuthBot в init.conf рядом с Core.dll."; + Console.WriteLine(msg); + TelegramAuthBotSerilog.Log.Warning("{Message}", msg); + } + } + + static TelegramAuthBotConf DefaultConfTemplate() => + new() + { + enable = true, + bot_token = "", + lampac_base_url = "http://127.0.0.1:9118", + request_timeout_sec = 10, + service_display_name = "Lampac NextGen Bot", + mutations_api_secret = "" + }; + } +} diff --git a/mods/TelegramAuthBot/Models/TelegramAuthApiDtos.cs b/mods/TelegramAuthBot/Models/TelegramAuthApiDtos.cs new file mode 100644 index 0000000..a32d5c8 --- /dev/null +++ b/mods/TelegramAuthBot/Models/TelegramAuthApiDtos.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; + +namespace TelegramAuthBot.Models +{ + public sealed class BindCompleteResult + { + public bool Ok { get; set; } + public bool PendingAdminApproval { get; set; } + } + + public class UserByTelegramDto + { + public bool found { get; set; } + public string telegramId { get; set; } + public string username { get; set; } + public string role { get; set; } + public string lang { get; set; } + public bool disabled { get; set; } + public bool registrationPending { get; set; } + public bool active { get; set; } + public string expiresAt { get; set; } + public int deviceCount { get; set; } + public int maxDevices { get; set; } + } + + public class DevicesResponseDto + { + public string telegramId { get; set; } + public string username { get; set; } + public List devices { get; set; } + } + + public class DeviceDto + { + public string uid { get; set; } + public string name { get; set; } + public bool active { get; set; } + } + + public class AdminUsersListResponseDto + { + public bool ok { get; set; } + public List users { get; set; } + } + + public class AdminUserAccsBriefDto + { + public int? group { get; set; } + public bool? IsPasswd { get; set; } + public bool? ban { get; set; } + public string ban_msg { get; set; } + public string comment { get; set; } + public List ids { get; set; } + } + + public class AdminUserRowDto + { + public string telegramId { get; set; } + public string username { get; set; } + public string role { get; set; } + public bool disabled { get; set; } + public bool registrationPending { get; set; } + public bool active { get; set; } + public string expiresAt { get; set; } + public int deviceCount { get; set; } + public AdminUserAccsBriefDto accs { get; set; } + } +} diff --git a/mods/TelegramAuthBot/Models/TelegramAuthBotConf.cs b/mods/TelegramAuthBot/Models/TelegramAuthBotConf.cs new file mode 100644 index 0000000..165d02a --- /dev/null +++ b/mods/TelegramAuthBot/Models/TelegramAuthBotConf.cs @@ -0,0 +1,23 @@ +namespace TelegramAuthBot.Models +{ + public class TelegramAuthBotConf + { + public bool enable { get; set; } = true; + + public string bot_token { get; set; } = ""; + + public string lampac_base_url { get; set; } = "http://127.0.0.1:9118"; + + public int request_timeout_sec { get; set; } = 10; + + public string service_display_name { get; set; } = "Lampac NextGen Bot"; + + public string mutations_api_secret { get; set; } = ""; + + public long[] admin_chat_ids { get; set; } + + public long[] owner_telegram_ids { get; set; } + + public bool notify_admins_on_pending_provision { get; set; } = true; + } +} diff --git a/mods/TelegramAuthBot/README.md b/mods/TelegramAuthBot/README.md new file mode 100644 index 0000000..dc57385 --- /dev/null +++ b/mods/TelegramAuthBot/README.md @@ -0,0 +1,84 @@ +# TelegramAuthBot + +Фоновый **Telegram-бот** (long polling): привязка UID устройства к учётке в [TelegramAuth](../TelegramAuth/README.md), статус, устройства, админ-импорт/очистка через HTTP. Проект: **`Modules/Community/TelegramAuthBot/`**. + +**Клиент Lampa** (замена `deny.js`, оверлей входа): [Community README](../README.md). + +## Включение + +1. В шаблоне [`config/base.conf`](../../../config/base.conf) **`TelegramAuthBot`** и **`TelegramAuth`** по умолчанию в **`BaseModule.SkipModules`** — уберите оба имени из списка, иначе модули не загрузятся. +2. [`manifest.json`](manifest.json): **`"enable": true`**. +3. `init.conf`: секция **`TelegramAuthBot`**. Пример: [`init.merge.example.json`](init.merge.example.json). + +При `enable: true` и пустом `bot_token` в лог пишется предупреждение; long polling **не стартует**. + +## Зависимости + +1. **Модуль TelegramAuth** должен быть загружен и доступен по HTTP с того хоста/порта, который вы укажете в `lampac_base_url`. +2. Профиль в TelegramAuth: либо уже есть запись, либо включён `auto_provision_users`, либо владелец попадает в базу при **старте Lampac** по `TelegramAuth.owner_telegram_ids` (см. [TelegramAuth](../TelegramAuth/README.md)). + +## Конфигурация (`TelegramAuthBot`) + +| Поле | Описание | +| ---- | -------- | +| `enable` | Включить бота. | +| `bot_token` | Токен от [@BotFather](https://t.me/BotFather). | +| `lampac_base_url` | Базовый URL Lampac **без** завершающего слэша, например `http://127.0.0.1:9118`. К нему добавляются пути вида `tg/auth/...`. | +| `request_timeout_sec` | Таймаут HTTP-клиента к Lampac (минимум 1 сек). | +| `service_display_name` | Отображаемое имя сервиса в текстах бота (HTML-экранирование учитывается в коде). | +| `mutations_api_secret` | Тот же секрет, что **`TelegramAuth.mutations_api_secret`**. Нужен для админ-команд и их HTTP-вызовов (`X-TelegramAuth-Mutations-Secret`). Если пусто — бот сообщит об этом. | +| `admin_chat_ids` | Если **не пустой** — админ-команды только из этих групп, **кроме** лички пользователей из `owner_telegram_ids`. Если **пустой** — из любого чата. | +| `owner_telegram_ids` | Числовые **user** id (как в `TelegramAuth.owner_telegram_ids`). Нужны, если задан `admin_chat_ids`, но владелец хочет вызывать `/users` из лички. | +| `notify_admins_on_pending_provision` | `true` по умолчанию. После успешной привязки UID, когда новый пользователь создан в режиме **ожидания подтверждения** (`auto_provision_activate_immediately: false`), бот рассылает администраторам (роль `admin` в базе) личные сообщения с кнопками «Принять» / «Отклонить`. Для запроса списка админов используется тот же API, что и `/users`; поэтому при **пустом** `mutations_api_secret` уведомления **не отправляются** (в лог пишется предупреждение). Установите `false`, чтобы отключить рассылку. | + +## Как это работает + +1. При старте регистрируется `HostedService`: проверка токена (`GetMe`), снятие webhook, цикл **`GetUpdates`** (long polling). +2. Для каждого апдейта создаётся сессия с HTTP-клиентом к `lampac_base_url` ([`LampacTelegramAuthHttpClient`](Services/LampacTelegramAuthHttpClient.cs)). +3. Привязка устройства: бот вызывает `GET .../tg/auth/user/by-telegram`. Если пользователь **найден**, но доступ **неактивен** (ожидание модерации, отключён админом или истёк срок), привязка **не выполняется** — пользователю отправляется пояснение. Если учётки **ещё нет** и включён `auto_provision_users`, привязка выполняется через `POST .../tg/auth/bind/complete` (создание пользователя и устройства на стороне API). Иначе — тот же `bind/complete` для уже существующей записи. При успешной регистрации с ожиданием подтверждения админам может уйти уведомление (см. **`notify_admins_on_pending_provision`**). Имя устройства в `users.json` клиент выставляет после входа (`POST .../tg/auth/device/name`) или пользователь — командой `/devicename` (см. [TelegramAuth](../TelegramAuth/README.md)). + +**Важно:** процесс должен видеть Lampac по сети. В Docker часто нужен URL вида `http://host.docker.internal:9118` или имя сервиса compose, а не только `127.0.0.1`, если бот крутится в другом контейнере. + +## Сценарий для пользователя + +1. Открыть клиент Lampac, получить **UID** на экране авторизации. +2. Написать боту в **личку** (в группах без `From` бот может не определить Telegram user id — см. сообщение об ошибке в коде). +3. Отправить UID текстом **или** открыть deep link: `https://t.me/?start=` (UID: 6–20 символов, `[a-zA-Z0-9_-]`). +4. В клиенте нажать «Проверить снова» (или аналог), который дергает `GET /tg/auth/status`. + +Кнопки меню: **Мой статус**, **Мои устройства**, **Помощь**. + +## Команды + +| Команда | Кто | Описание | +| ------- | --- | -------- | +| `/start` | Все | Приветствие и инструкция; вариант `/start ` — быстрая привязка. | +| `/help` | Все | Подсказка по входу и кнопкам. | +| `/me` | Все | Профиль из API (роль, срок, число устройств, лимит). | +| `/devices` | Все | Список устройств; для активных — **Отвязать**, для отключённых — **✅ Включить** (снова активирует UID на сервере). | +| `/devicename` | Все | `/devicename <имя>` — имя в `Devices[].Name` через `POST /tg/auth/device/name` (только **свой** активный UID). Вместо имени `-` сбрасывает подпись. Секрет мутаций не нужен. | +| `/users` | Админы TelegramAuth | Список пользователей (постранично); в строке кратко видно **`accs`** (группа, `ban`, …). Ожидающие — **Принять** / **Отклонить**; остальные — **Выкл** / **Вкл**. | +| `/user` | Админы | `/user ` — карточка, JSON **`accs`**, устройства (`GET /tg/auth/admin/user`). | +| `/setuser` | Админы | Правка профиля и **`accs`** через `POST /tg/auth/admin/user/patch` (срок, роль, `group`, `ban`, `banmsg`, `comment`, `ids`, `param`, сброс полей — см. подсказку без аргументов). | +| `/import` | Админы TelegramAuth | `POST /tg/auth/import` (секрет + роль `admin` + при необходимости чат из `admin_chat_ids`). | +| `/cleanup` | Админы TelegramAuth | `POST /tg/auth/devices/cleanup`. | + +Админ определяется по полю **роли** в ответе API пользователя (`role == admin`), не по списку `admin_chat_ids` (этот список только ограничивает **чаты**, не роли). + +## Ссылки на код + +- Регистрация сервиса: [`ModInit.cs`](ModInit.cs) (`IModuleConfigure` → `AddHostedService`). +- Long polling и жизненный цикл: [`TelegramAuthBotHostedService.cs`](Services/TelegramAuthBotHostedService.cs). +- Диалоги и команды: [`TelegramAuthBotSession.cs`](Services/TelegramAuthBotSession.cs). + +## Устранение неполадок + +| Симптом | Что проверить | +| ------- | ------------- | +| Бот молчит при старте | `enable`, `bot_token`, сеть до `api.telegram.org`, логи Serilog / консоль. | +| «Тебя нет в базе» | Запись пользователя с вашим Telegram ID в `users.json` (TelegramAuth), импорт или ручное добавление. | +| Привязка не срабатывает | Lampac доступен с хоста бота по `lampac_base_url`; пользователь `active`; лимит устройств не превышен. | +| `/import` или `/cleanup` не работают | Непустой `mutations_api_secret` (бот не подставляет root-cookie); значение совпадает с `TelegramAuth.mutations_api_secret`; роль `admin`; если задан `admin_chat_ids` — команда из этой группы или из лички при наличии твоего id в `owner_telegram_ids`. | +| Админы не получили уведомление о новой заявке | Включён `notify_admins_on_pending_provision`; задан ненулевой `mutations_api_secret`; в `users.json` есть хотя бы один пользователь с ролью `admin`. | + +Подробное описание HTTP API см. в [README модуля TelegramAuth](../TelegramAuth/README.md). diff --git a/mods/TelegramAuthBot/Services/LampacTelegramAuthHttpClient.cs b/mods/TelegramAuthBot/Services/LampacTelegramAuthHttpClient.cs new file mode 100644 index 0000000..24e7c06 --- /dev/null +++ b/mods/TelegramAuthBot/Services/LampacTelegramAuthHttpClient.cs @@ -0,0 +1,185 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using TelegramAuthBot.Models; + +namespace TelegramAuthBot.Services +{ + sealed class LampacTelegramAuthHttpClient : IDisposable + { + public const string MutationsSecretHeaderName = "X-TelegramAuth-Mutations-Secret"; + + readonly HttpClient _http; + readonly string _mutationsSecret; + + public LampacTelegramAuthHttpClient(TelegramAuthBotConf conf) + { + var baseUrl = (conf.lampac_base_url ?? "").Trim().TrimEnd('/') + "/"; + _http = new HttpClient { BaseAddress = new Uri(baseUrl, UriKind.Absolute) }; + _http.Timeout = TimeSpan.FromSeconds(Math.Max(1, conf.request_timeout_sec)); + _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _mutationsSecret = conf.mutations_api_secret?.Trim() ?? ""; + } + + public void Dispose() => _http.Dispose(); + + public async Task GetUserByTelegramAsync(string telegramId, CancellationToken ct) + { + var path = "tg/auth/user/by-telegram?" + Uri.EscapeDataString("telegramId") + "=" + Uri.EscapeDataString(telegramId); + using var resp = await _http.GetAsync(path, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + return null; + return JsonConvert.DeserializeObject(body); + } + + public async Task GetDevicesAsync(string telegramId, CancellationToken ct) + { + var path = "tg/auth/devices?" + Uri.EscapeDataString("telegramId") + "=" + Uri.EscapeDataString(telegramId); + using var resp = await _http.GetAsync(path, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (resp.StatusCode == HttpStatusCode.NotFound) + return null; + if (!resp.IsSuccessStatusCode) + return null; + return JsonConvert.DeserializeObject(body); + } + + public async Task BindCompleteAsync(string uid, string telegramId, string username, CancellationToken ct) + { + var payload = new { uid, telegramId, username }; + using var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json"); + using var req = new HttpRequestMessage(HttpMethod.Post, "tg/auth/bind/complete") { Content = content }; + AddMutationsSecret(req); + using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + return new BindCompleteResult(); + var jo = JObject.Parse(body); + return new BindCompleteResult + { + Ok = jo.Value("ok") == true, + PendingAdminApproval = jo.Value("pendingAdminApproval") == true + }; + } + + public async Task UnbindDeviceAsync(string telegramId, string uid, CancellationToken ct) + { + var payload = new { telegramId, uid }; + using var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json"); + using var resp = await _http.PostAsync("tg/auth/device/unbind", content, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + return false; + var jo = JObject.Parse(body); + return jo.Value("ok") == true; + } + + public async Task<(bool ok, string detail)> ReactivateDeviceAsync(string telegramId, string uid, CancellationToken ct) + { + var payload = new { telegramId, uid }; + using var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json"); + using var resp = await _http.PostAsync("tg/auth/device/reactivate", content, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return resp.IsSuccessStatusCode ? (true, body) : (false, body); + } + + public async Task<(bool ok, string detail)> SetDeviceDisplayNameAsync(string uid, string name, CancellationToken ct) + { + var payload = new { uid, name }; + using var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json"); + using var resp = await _http.PostAsync("tg/auth/device/name", content, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return resp.IsSuccessStatusCode ? (true, body) : (false, body); + } + + public async Task<(bool ok, string detail)> ImportLegacyAsync(CancellationToken ct) + { + using var req = new HttpRequestMessage(HttpMethod.Post, "tg/auth/import"); + AddMutationsSecret(req); + using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return resp.IsSuccessStatusCode ? (true, body) : (false, body); + } + + public async Task<(bool ok, string detail)> CleanupDevicesAsync(CancellationToken ct) + { + using var req = new HttpRequestMessage(HttpMethod.Post, "tg/auth/devices/cleanup"); + AddMutationsSecret(req); + using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return resp.IsSuccessStatusCode ? (true, body) : (false, body); + } + + public async Task GetAdminUsersAsync(CancellationToken ct) + { + using var req = new HttpRequestMessage(HttpMethod.Get, "tg/auth/admin/users"); + AddMutationsSecret(req); + using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + return null; + return JsonConvert.DeserializeObject(body); + } + + public async Task<(bool ok, string detail)> SetUserDisabledAsync(string telegramId, bool disabled, CancellationToken ct) + { + var payload = new { telegramId, disabled }; + using var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json"); + using var req = new HttpRequestMessage(HttpMethod.Post, "tg/auth/admin/user/disabled") { Content = content }; + AddMutationsSecret(req); + using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return resp.IsSuccessStatusCode ? (true, body) : (false, body); + } + + public async Task<(bool ok, string detail)> ResolveRegistrationPendingAsync(string telegramId, bool approve, CancellationToken ct) + { + var payload = new { telegramId, approve }; + using var content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json"); + using var req = new HttpRequestMessage(HttpMethod.Post, "tg/auth/admin/user/pending") { Content = content }; + AddMutationsSecret(req); + using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return resp.IsSuccessStatusCode ? (true, body) : (false, body); + } + + public async Task<(bool ok, JObject data, string errorBody)> GetAdminUserDetailAsync(string telegramId, CancellationToken ct) + { + var path = "tg/auth/admin/user?" + Uri.EscapeDataString("telegramId") + "=" + Uri.EscapeDataString(telegramId); + using var req = new HttpRequestMessage(HttpMethod.Get, path); + AddMutationsSecret(req); + using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + return (false, null, body); + try + { + return (true, JObject.Parse(body), body); + } + catch + { + return (false, null, body); + } + } + + public async Task<(bool ok, string detail)> PatchAdminUserAsync(JObject patch, CancellationToken ct) + { + using var content = new StringContent(patch.ToString(Formatting.None), Encoding.UTF8, "application/json"); + using var req = new HttpRequestMessage(HttpMethod.Post, "tg/auth/admin/user/patch") { Content = content }; + AddMutationsSecret(req); + using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return resp.IsSuccessStatusCode ? (true, body) : (false, body); + } + + void AddMutationsSecret(HttpRequestMessage req) + { + if (_mutationsSecret.Length > 0) + req.Headers.TryAddWithoutValidation(MutationsSecretHeaderName, _mutationsSecret); + } + } +} diff --git a/mods/TelegramAuthBot/Services/TelegramAuthBotHostedService.cs b/mods/TelegramAuthBot/Services/TelegramAuthBotHostedService.cs new file mode 100644 index 0000000..9ead06d --- /dev/null +++ b/mods/TelegramAuthBot/Services/TelegramAuthBotHostedService.cs @@ -0,0 +1,133 @@ +using TelegramAuthBot.Models; + +namespace TelegramAuthBot.Services +{ + public sealed class TelegramAuthBotHostedService : BackgroundService + { + const int GetUpdatesLimit = 100; + const int GetUpdatesTimeoutSeconds = 50; + static readonly TimeSpan GetUpdatesErrorDelay = TimeSpan.FromSeconds(5); + + public TelegramAuthBotHostedService() + { + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await RunBotAsync(stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + TelegramAuthBotSerilog.Log.Error(ex, "CatchId={CatchId}", "id_tgauthbot_execute"); + throw; + } + } + + async Task RunBotAsync(CancellationToken stoppingToken) + { + var conf = ModInit.conf; + if (!conf.enable || string.IsNullOrWhiteSpace(conf.bot_token)) + { + var msg = "TelegramAuthBot: отключён (enable=false или пустой bot_token); long polling не запущен."; + Console.WriteLine(msg); + TelegramAuthBotSerilog.Log.Information("{Message}", msg); + return; + } + + using var api = new LampacTelegramAuthHttpClient(conf); + var bot = new TelegramBotClient(conf.bot_token.Trim()); + var displayName = conf.service_display_name ?? "Lampac NextGen Bot"; + + try + { + var me = await bot.GetMe(stoppingToken).ConfigureAwait(false); + var line = $"TelegramAuthBot: @{me.Username} — Telegram API OK; TelegramAuth HTTP {conf.lampac_base_url}"; + Console.WriteLine(line); + TelegramAuthBotSerilog.Log.Information("Telegram OK @{BotUsername}; TelegramAuth {BaseUrl}", me.Username, conf.lampac_base_url); + } + catch (Exception ex) + { + Console.WriteLine("TelegramAuthBot: GetMe не удался (токен или сеть до api.telegram.org). " + ex.Message); + TelegramAuthBotSerilog.Log.Error(ex, "CatchId={CatchId}", "id_tgauthbot_getme"); + return; + } + + try + { + await bot.DeleteWebhook(dropPendingUpdates: false, stoppingToken).ConfigureAwait(false); + } + catch (Exception ex) + { + TelegramAuthBotSerilog.Log.Error(ex, "CatchId={CatchId}", "id_tgauthbot_deletewebhook"); + } + + var session = new TelegramAuthBotSession(api, displayName); + var pollMsg = + $"TelegramAuthBot: long polling GetUpdates (limit {GetUpdatesLimit}, timeout {GetUpdatesTimeoutSeconds}s)."; + Console.WriteLine(pollMsg); + TelegramAuthBotSerilog.Log.Information( + "Long polling GetUpdates limit {Limit} timeout {TimeoutSeconds}s", + GetUpdatesLimit, GetUpdatesTimeoutSeconds); + + await PollUpdatesAsync(bot, session, stoppingToken).ConfigureAwait(false); + } + + async Task PollUpdatesAsync(ITelegramBotClient bot, TelegramAuthBotSession session, CancellationToken stoppingToken) + { + int? offset = null; + while (!stoppingToken.IsCancellationRequested) + { + Update[] updates; + try + { + updates = await bot.GetUpdates( + offset, + limit: GetUpdatesLimit, + timeout: GetUpdatesTimeoutSeconds, + allowedUpdates: null, + cancellationToken: stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + TelegramAuthBotSerilog.Log.Error(ex, "CatchId={CatchId}, DelaySeconds={Delay}", "id_tgauthbot_getupdates", GetUpdatesErrorDelay.TotalSeconds); + try + { + await Task.Delay(GetUpdatesErrorDelay, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + + continue; + } + + foreach (var update in updates) + { + offset = update.Id + 1; + try + { + await session.HandleUpdateAsync(bot, update, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + TelegramAuthBotSerilog.Log.Error(ex, "CatchId={CatchId}, UpdateId={UpdateId}", "id_tgauthbot_update", update.Id); + } + } + } + } + } +} diff --git a/mods/TelegramAuthBot/Services/TelegramAuthBotSerilog.cs b/mods/TelegramAuthBot/Services/TelegramAuthBotSerilog.cs new file mode 100644 index 0000000..a40dcb3 --- /dev/null +++ b/mods/TelegramAuthBot/Services/TelegramAuthBotSerilog.cs @@ -0,0 +1,8 @@ +namespace TelegramAuthBot.Services +{ + internal static class TelegramAuthBotSerilog + { + public static readonly Serilog.ILogger Log = + Serilog.Log.ForContext("SourceContext", "TelegramAuthBot"); + } +} diff --git a/mods/TelegramAuthBot/Services/TelegramAuthBotSession.Admin.cs b/mods/TelegramAuthBot/Services/TelegramAuthBotSession.Admin.cs new file mode 100644 index 0000000..c9a34a2 --- /dev/null +++ b/mods/TelegramAuthBot/Services/TelegramAuthBotSession.Admin.cs @@ -0,0 +1,602 @@ +using Newtonsoft.Json; +using Telegram.Bot.Types; +using TelegramAuthBot.Models; + +namespace TelegramAuthBot.Services +{ + sealed partial class TelegramAuthBotSession + { + static bool IsTelegramAuthAdmin(UserByTelegramDto user) => + user != null && user.found && string.Equals(user.role, "admin", StringComparison.OrdinalIgnoreCase); + + static bool IsAllowedAdminCommandContext(TelegramAuthBotConf conf, ChatType chatType, long chatId, long actorTelegramUserId) + { + var ids = conf.admin_chat_ids; + if (ids == null || ids.Length == 0) + return true; + + if (ids.Contains(chatId)) + return true; + + if (chatType == ChatType.Private) + { + var owners = conf.owner_telegram_ids; + if (owners != null && owners.Length > 0 && owners.Contains(actorTelegramUserId)) + return true; + } + + return false; + } + + async Task TryEnsureAdminMutationAccessAsync(ITelegramBotClient bot, Chat chat, string tgId, CancellationToken ct) + { + var conf = ModInit.conf; + if (string.IsNullOrEmpty(conf.mutations_api_secret)) + { + await bot.SendMessage(chat.Id, + "В конфиге бота не задан mutations_api_secret — тот же секрет, что TelegramAuth.mutations_api_secret в init.conf.", + parseMode: ParseMode.Html, + cancellationToken: ct).ConfigureAwait(false); + return false; + } + + if (!long.TryParse(tgId?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var actorId)) + actorId = 0; + + if (!IsAllowedAdminCommandContext(conf, chat.Type, chat.Id, actorId)) + { + await bot.SendMessage(chat.Id, + "Админ-команды с этого чата запрещены. Из лички: добавь свой id в owner_telegram_ids бота (те же числа, что TelegramAuth.owner_telegram_ids) или пиши из чата admin_chat_ids. Пустой admin_chat_ids — админ-команды из любого чата, в т.ч. лички.", + parseMode: ParseMode.Html, + cancellationToken: ct).ConfigureAwait(false); + return false; + } + + var user = await _api.GetUserByTelegramAsync(tgId, ct).ConfigureAwait(false); + if (!IsTelegramAuthAdmin(user)) + { + await bot.SendMessage(chat.Id, + "Команда только для администраторов (роль admin в базе TelegramAuth). Остальные пользователи входят через UID; ты включаешь их в /users.", + parseMode: ParseMode.Html, + cancellationToken: ct).ConfigureAwait(false); + return false; + } + + return true; + } + + async Task TryEnsureAdminForCallbackAsync(ITelegramBotClient bot, CallbackQuery cq, CancellationToken ct) + { + var chat = cq.Message?.Chat; + var chatId = chat?.Id ?? cq.From.Id; + var conf = ModInit.conf; + if (string.IsNullOrEmpty(conf.mutations_api_secret)) + { + await bot.SendMessage(chatId, + "В конфиге бота не задан mutations_api_secret — тот же секрет, что TelegramAuth.mutations_api_secret в init.conf.", + parseMode: ParseMode.Html, + cancellationToken: ct).ConfigureAwait(false); + return false; + } + + var chatType = chat?.Type ?? ChatType.Private; + if (!IsAllowedAdminCommandContext(conf, chatType, chatId, cq.From.Id)) + { + await bot.SendMessage(chatId, + "Админ-команды с этого чата запрещены. Для лички добавь свой id в owner_telegram_ids или используй чат из admin_chat_ids.", + parseMode: ParseMode.Html, + cancellationToken: ct).ConfigureAwait(false); + return false; + } + + var tgId = cq.From.Id.ToString(); + var user = await _api.GetUserByTelegramAsync(tgId, ct).ConfigureAwait(false); + if (!IsTelegramAuthAdmin(user)) + { + await bot.SendMessage(chatId, + "Команда только для администраторов (роль admin в базе TelegramAuth).", + parseMode: ParseMode.Html, + cancellationToken: ct).ConfigureAwait(false); + return false; + } + + return true; + } + + static string ShortUserLabel(AdminUserRowDto u) + { + var n = string.IsNullOrEmpty(u.username) ? u.telegramId : u.username; + if (n.Length > 14) + n = n.Substring(0, 12) + "…"; + return n; + } + + static (string text, InlineKeyboardMarkup markup) BuildAdminUsersPage(AdminUsersListResponseDto data, int page, string actorTgId) + { + var all = data.users ?? new List(); + var total = all.Count; + var totalPages = total == 0 ? 1 : (total + AdminUsersPageSize - 1) / AdminUsersPageSize; + if (page < 0) page = 0; + if (page >= totalPages) page = totalPages - 1; + + var slice = all.Skip(page * AdminUsersPageSize).Take(AdminUsersPageSize).ToList(); + var lines = new List + { + $"Пользователи TelegramAuth · всего {total} · стр. {page + 1}/{totalPages}\n" + }; + + foreach (var u in slice) + { + var adm = string.Equals(u.role, "admin", StringComparison.OrdinalIgnoreCase); + var st = u.registrationPending + ? "⏳ ждёт подтв." + : u.disabled ? "🔒 отключён" : u.active ? "✅ доступ" : "⏸ нет доступа"; + var tag = string.IsNullOrEmpty(u.username) ? "—" : "@" + EscapeHtml(u.username); + var accsHint = ""; + if (u.accs != null) + { + var bits = new List(); + if (u.accs.group.HasValue) + bits.Add($"g:{u.accs.group.Value}"); + if (u.accs.ban == true) + bits.Add("ban"); + if (u.accs.IsPasswd == true) + bits.Add("passwd"); + if (!string.IsNullOrEmpty(u.accs.comment)) + bits.Add(TruncatePlain(u.accs.comment, 18)); + if (bits.Count > 0) + accsHint = " · " + EscapeHtml(string.Join(", ", bits)) + ""; + } + + lines.Add($"{tag} · {EscapeHtml(u.telegramId)} · {st}{(adm ? " · admin" : "")}{accsHint}"); + } + + if (total == 0) + lines.Add("\nЗаписей пока нет."); + + var rows = new List(); + foreach (var u in slice) + { + var isAdmin = string.Equals(u.role, "admin", StringComparison.OrdinalIgnoreCase); + var self = string.Equals(u.telegramId, actorTgId, StringComparison.Ordinal); + if (isAdmin || self) + continue; + + var label = ShortUserLabel(u); + if (u.registrationPending) + { + var cbA = $"{CbApprovePending}{page}|{u.telegramId}"; + var cbR = $"{CbRejectPending}{page}|{u.telegramId}"; + if (Encoding.UTF8.GetByteCount(cbA) <= 64 && Encoding.UTF8.GetByteCount(cbR) <= 64) + { + rows.Add(new[] + { + InlineKeyboardButton.WithCallbackData($"✅ Принять · {label}", cbA), + InlineKeyboardButton.WithCallbackData($"❌ Отклонить · {label}", cbR) + }); + } + + continue; + } + + var cb = u.disabled + ? $"{CbEnableUser}{page}|{u.telegramId}" + : $"{CbDisableUser}{page}|{u.telegramId}"; + if (Encoding.UTF8.GetByteCount(cb) <= 64) + { + var title = u.disabled ? $"✅ Вкл · {label}" : $"🚫 Выкл · {label}"; + rows.Add(new[] { InlineKeyboardButton.WithCallbackData(title, cb) }); + } + } + + if (totalPages > 1) + { + var nav = new List(); + if (page > 0) + nav.Add(InlineKeyboardButton.WithCallbackData("◀️", CbUsersPage + (page - 1))); + if (page < totalPages - 1) + nav.Add(InlineKeyboardButton.WithCallbackData("▶️", CbUsersPage + (page + 1))); + if (nav.Count > 0) + rows.Add(nav.ToArray()); + } + + var markup = rows.Count > 0 ? new InlineKeyboardMarkup(rows) : null; + return (string.Join("\n", lines), markup); + } + + async Task SendOrEditAdminUsersPageAsync(ITelegramBotClient bot, ChatId chatId, int? messageId, AdminUsersListResponseDto data, int page, string actorTgId, CancellationToken ct) + { + var (text, markup) = BuildAdminUsersPage(data, page, actorTgId); + if (messageId.HasValue) + { + await bot.EditMessageText(chatId, messageId.Value, text, parseMode: ParseMode.Html, replyMarkup: markup, cancellationToken: ct).ConfigureAwait(false); + } + else + { + await bot.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: markup, cancellationToken: ct).ConfigureAwait(false); + } + } + + async Task CmdUsersAsync(ITelegramBotClient bot, Chat chat, string tgId, CancellationToken ct) + { + if (!await TryEnsureAdminMutationAccessAsync(bot, chat, tgId, ct).ConfigureAwait(false)) + return; + + var data = await _api.GetAdminUsersAsync(ct).ConfigureAwait(false); + if (data == null || !data.ok) + { + await bot.SendMessage(chat.Id, "❌ Не удалось загрузить список пользователей (проверь секрет и доступ к Lampac).", cancellationToken: ct).ConfigureAwait(false); + return; + } + + await SendOrEditAdminUsersPageAsync(bot, chat.Id, null, data, 0, tgId, ct).ConfigureAwait(false); + } + + static string TruncatePlain(string s, int max) + { + if (string.IsNullOrEmpty(s) || s.Length <= max) + return s ?? ""; + return s.Substring(0, max) + "…"; + } + + async Task CmdAdminUserDetailAsync(ITelegramBotClient bot, Chat chat, string actorTgId, string text, CancellationToken ct) + { + if (!await TryEnsureAdminMutationAccessAsync(bot, chat, actorTgId, ct).ConfigureAwait(false)) + return; + + var m = AdminUserDetailRe.Match(text.Trim()); + if (!m.Success) + { + await bot.SendMessage(chat.Id, + "Просмотр пользователя (accsdb-поля)\n\n/user <telegramId>\n\nПоказывает accs, срок expiresAt, устройства. Редактирование — /setuser.", + parseMode: ParseMode.Html, + cancellationToken: ct).ConfigureAwait(false); + return; + } + + var targetId = m.Groups[1].Value; + var (ok, jo, errBody) = await _api.GetAdminUserDetailAsync(targetId, ct).ConfigureAwait(false); + if (!ok || jo == null) + { + await bot.SendMessage(chat.Id, + "❌ Не удалось загрузить пользователя.\n" + TruncateForTelegram(StripJsonError(errBody), 800), + cancellationToken: ct).ConfigureAwait(false); + return; + } + + var role = jo.Value("role") ?? "—"; + var lang = jo.Value("lang") ?? "—"; + var uname = jo.Value("username"); + var active = jo.Value("active") == true; + var disabled = jo.Value("disabled") == true; + var pending = jo.Value("registrationPending") == true; + var exp = jo["expiresAt"]?.ToString() ?? "—"; + var maxDev = jo.Value("maxDevices"); + var devCount = jo.Value("deviceCount"); + var maxStr = maxDev == -1 ? "∞" : (maxDev?.ToString() ?? "—"); + + var accsJson = jo["accs"] is JToken at && at.Type != JTokenType.Null + ? at.ToString(Formatting.Indented) + : "(нет — действуют значения по умолчанию из роли и init)"; + + var devLines = new List(); + if (jo["devices"] is JArray arr) + { + foreach (var d in arr.Take(12)) + { + var uid = d.Value("uid") ?? ""; + var nm = d.Value("name"); + var act = d.Value("active") == true; + var nmShow = string.IsNullOrEmpty(nm) ? "—" : nm; + devLines.Add($"• {EscapeHtml(uid)} · {EscapeHtml(nmShow)} · {(act ? "on" : "off")}"); + } + + if (arr.Count > 12) + devLines.Add($"… ещё {arr.Count - 12}"); + } + + var devBlock = devLines.Count > 0 ? string.Join("\n", devLines) : "нет устройств"; + + var msg = + $"Пользователь {EscapeHtml(targetId)}\n" + + $"@{EscapeHtml(string.IsNullOrEmpty(uname) ? "—" : uname)} · роль {EscapeHtml(role)} · lang {EscapeHtml(lang)}\n" + + $"активен: {(active ? "да" : "нет")} · отключён: {(disabled ? "да" : "нет")} · ожидание: {(pending ? "да" : "нет")}\n" + + $"Срок (ExpiresAt): {EscapeHtml(exp)}\n" + + $"Устройства: {devCount ?? 0} / {maxStr}\n\n" + + "accs (синхронизируется в корневой users.json):\n
" + EscapeHtml(accsJson) + "
\n\n" + + "Устройства:\n" + devBlock; + + await bot.SendMessage(chat.Id, msg, parseMode: ParseMode.Html, cancellationToken: ct).ConfigureAwait(false); + } + + async Task CmdAdminSetUserAsync(ITelegramBotClient bot, Chat chat, string actorTgId, string text, CancellationToken ct) + { + if (!await TryEnsureAdminMutationAccessAsync(bot, chat, actorTgId, ct).ConfigureAwait(false)) + return; + + var m = AdminSetUserRe.Match(text.Trim()); + if (!m.Success) + { + await bot.SendMessage(chat.Id, + "Изменение пользователя (accsdb)\n\n" + + "/setuser <telegramId> <команда> …\n\n" + + "Команды:\n" + + "• group <n> или clear\n" + + "• expires <дата ISO или yyyy-MM-dd> или clear\n" + + "• role user | admin\n" + + "• lang <код>\n" + + "• comment <текст>\n" + + "• banmsg <текст>\n" + + "• passwd on | off\n" + + "• ban on | off — бан в accsdb (учётка TG активна); текст — banmsg\n" + + "• ids uid1,uid2 или clear\n" + + "• param ключ=значение\n" + + "• clearparam ключ\n" + + "• clear group ban ban_msg comment … — сброс полей accs\n\n" + + "Пока аккаунт ожидает подтверждения, UID не попадает в accsdb (при привязке лишний UID удаляется из users.json).\n\n" + + "После изменения при включённом sync данные уходят в корневой users.json.", + parseMode: ParseMode.Html, + cancellationToken: ct).ConfigureAwait(false); + return; + } + + var targetId = m.Groups[1].Value; + var sub = m.Groups[2].Value.Trim().ToLowerInvariant(); + var rest = m.Groups[3].Value.Trim(); + + var patch = new JObject { ["telegramId"] = targetId }; + string err; + + switch (sub) + { + case "group": + if (string.IsNullOrEmpty(rest)) + err = "Укажи число или clear."; + else if (rest.Equals("clear", StringComparison.OrdinalIgnoreCase)) + { + patch["accsRemove"] = new JArray("group"); + err = null; + } + else if (int.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var g)) + { + patch["accs"] = new JObject { ["group"] = g }; + err = null; + } + else + err = "group: нужно целое число или clear."; + break; + + case "expires": + if (string.IsNullOrEmpty(rest)) + err = "Укажи дату или clear."; + else if (rest.Equals("clear", StringComparison.OrdinalIgnoreCase)) + { + patch["expiresAt"] = JValue.CreateNull(); + err = null; + } + else + { + patch["expiresAt"] = rest; + err = null; + } + + break; + + case "role": + if (!string.Equals(rest, "user", StringComparison.OrdinalIgnoreCase) + && !string.Equals(rest, "admin", StringComparison.OrdinalIgnoreCase)) + err = "role: user или admin."; + else + { + patch["role"] = rest.ToLowerInvariant() == "admin" ? "admin" : "user"; + err = null; + } + + break; + + case "lang": + if (string.IsNullOrEmpty(rest)) + err = "Укажи код языка."; + else + { + patch["lang"] = rest; + err = null; + } + + break; + + case "comment": + patch["accs"] = new JObject { ["comment"] = rest }; + err = null; + break; + + case "banmsg": + patch["accs"] = new JObject { ["ban_msg"] = rest }; + err = null; + break; + + case "passwd": + { + var p = rest.ToLowerInvariant(); + if (p is "on" or "1" or "true" or "yes") + { + patch["accs"] = new JObject { ["IsPasswd"] = true }; + err = null; + } + else if (p is "off" or "0" or "false" or "no") + { + patch["accs"] = new JObject { ["IsPasswd"] = false }; + err = null; + } + else + err = "passwd: on или off."; + break; + } + + case "ban": + { + var p = rest.ToLowerInvariant(); + if (p is "on" or "1" or "true" or "yes") + { + patch["accs"] = new JObject { ["ban"] = true }; + err = null; + } + else if (p is "off" or "0" or "false" or "no") + { + patch["accs"] = new JObject { ["ban"] = false }; + err = null; + } + else + err = "ban: on или off."; + break; + } + + case "ids": + if (string.IsNullOrEmpty(rest)) + err = "Укажи список uid через запятую или clear."; + else if (rest.Equals("clear", StringComparison.OrdinalIgnoreCase)) + { + patch["accsRemove"] = new JArray("ids"); + err = null; + } + else + { + var ids = rest.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .ToList(); + if (ids.Count == 0) + err = "Пустой список ids."; + else + { + patch["accs"] = new JObject { ["ids"] = new JArray(ids) }; + err = null; + } + } + + break; + + case "param": + { + var eq = rest.IndexOf('='); + if (eq < 1 || eq >= rest.Length - 1) + err = "Формат: param ключ=значение"; + else + { + var k = rest.Substring(0, eq).Trim(); + var v = rest.Substring(eq + 1); + if (string.IsNullOrEmpty(k)) + err = "Пустой ключ."; + else + { + patch["accs"] = new JObject { ["params"] = new JObject { [k] = v } }; + err = null; + } + } + + break; + } + + case "clearparam": + if (string.IsNullOrEmpty(rest)) + err = "Укажи ключ."; + else + { + patch["paramsRemove"] = new JArray(rest.Trim()); + err = null; + } + + break; + + case "clear": + { + var keys = rest.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim().ToLowerInvariant()) + .Where(s => s.Length > 0) + .ToList(); + if (keys.Count == 0) + err = "Укажи поля: group, ispasswd, ban, ban_msg, comment, ids, params"; + else + { + patch["accsRemove"] = new JArray(keys); + err = null; + } + + break; + } + + default: + err = "Неизвестная команда. Смотри /setuser без аргументов."; + break; + } + + if (err != null) + { + await bot.SendMessage(chat.Id, "❌ " + err, parseMode: ParseMode.Html, cancellationToken: ct).ConfigureAwait(false); + return; + } + + var (pok, detail) = await _api.PatchAdminUserAsync(patch, ct).ConfigureAwait(false); + if (pok) + await bot.SendMessage(chat.Id, $"✅ Сохранено для {EscapeHtml(targetId)}.", parseMode: ParseMode.Html, cancellationToken: ct).ConfigureAwait(false); + else + await bot.SendMessage(chat.Id, + "❌ Ошибка API:\n" + TruncateForTelegram(StripJsonError(detail), 1200), + cancellationToken: ct).ConfigureAwait(false); + } + + async Task CmdImportAsync(ITelegramBotClient bot, Chat chat, string tgId, CancellationToken ct) + { + if (!await TryEnsureAdminMutationAccessAsync(bot, chat, tgId, ct).ConfigureAwait(false)) + return; + + await bot.SendMessage(chat.Id, "⏳ Запускаю импорт…", cancellationToken: ct).ConfigureAwait(false); + var (ok, detail) = await _api.ImportLegacyAsync(ct).ConfigureAwait(false); + if (ok) + { + try + { + var jo = JObject.Parse(detail); + var msg = + "✅ Импорт завершён.\n" + + $"Пользователей: {jo.Value("importedUsers") ?? 0}, устройств: {jo.Value("importedDevices") ?? 0}, админов: {jo.Value("importedAdmins") ?? 0}, языков: {jo.Value("importedLangs") ?? 0}"; + await bot.SendMessage(chat.Id, msg, cancellationToken: ct).ConfigureAwait(false); + } + catch + { + await bot.SendMessage(chat.Id, "✅ Импорт завершён.\n" + TruncateForTelegram(detail, 3500), cancellationToken: ct).ConfigureAwait(false); + } + } + else + { + await bot.SendMessage(chat.Id, "❌ Ошибка импорта:\n" + TruncateForTelegram(detail, 3500), cancellationToken: ct).ConfigureAwait(false); + } + } + + async Task CmdCleanupAsync(ITelegramBotClient bot, Chat chat, string tgId, CancellationToken ct) + { + if (!await TryEnsureAdminMutationAccessAsync(bot, chat, tgId, ct).ConfigureAwait(false)) + return; + + await bot.SendMessage(chat.Id, "⏳ Очистка неактивных устройств…", cancellationToken: ct).ConfigureAwait(false); + var (ok, detail) = await _api.CleanupDevicesAsync(ct).ConfigureAwait(false); + if (ok) + { + try + { + var jo = JObject.Parse(detail); + var removed = jo.Value("removed") ?? 0; + await bot.SendMessage(chat.Id, $"✅ Готово. Удалено записей устройств: {removed}", cancellationToken: ct).ConfigureAwait(false); + } + catch + { + await bot.SendMessage(chat.Id, "✅ Готово.\n" + TruncateForTelegram(detail, 3500), cancellationToken: ct).ConfigureAwait(false); + } + } + else + { + await bot.SendMessage(chat.Id, "❌ Ошибка очистки:\n" + TruncateForTelegram(detail, 3500), cancellationToken: ct).ConfigureAwait(false); + } + } + } +} diff --git a/mods/TelegramAuthBot/Services/TelegramAuthBotSession.Callbacks.cs b/mods/TelegramAuthBot/Services/TelegramAuthBotSession.Callbacks.cs new file mode 100644 index 0000000..de473f2 --- /dev/null +++ b/mods/TelegramAuthBot/Services/TelegramAuthBotSession.Callbacks.cs @@ -0,0 +1,233 @@ +using Telegram.Bot.Types; +using TelegramAuthBot.Models; + +namespace TelegramAuthBot.Services +{ + sealed partial class TelegramAuthBotSession + { + async Task HandleCallbackAsync(ITelegramBotClient bot, CallbackQuery cq, CancellationToken ct) + { + var data = cq.Data ?? ""; + if (data.StartsWith("unbind:", StringComparison.Ordinal)) + { + await HandleUnbindDeviceCallbackAsync(bot, cq, ct).ConfigureAwait(false); + return; + } + + if (data.StartsWith(CbReactivateDevice, StringComparison.Ordinal)) + { + await HandleReactivateDeviceCallbackAsync(bot, cq, ct).ConfigureAwait(false); + return; + } + + if (data.StartsWith(CbApprovePending, StringComparison.Ordinal) + || data.StartsWith(CbRejectPending, StringComparison.Ordinal)) + { + await HandlePendingRegistrationCallbackAsync(bot, cq, ct).ConfigureAwait(false); + return; + } + + if (data.StartsWith(CbUsersPage, StringComparison.Ordinal) + || data.StartsWith(CbDisableUser, StringComparison.Ordinal) + || data.StartsWith(CbEnableUser, StringComparison.Ordinal)) + { + await HandleAdminUsersInlineAsync(bot, cq, ct).ConfigureAwait(false); + return; + } + + await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false); + } + + async Task HandleUnbindDeviceCallbackAsync(ITelegramBotClient bot, CallbackQuery cq, CancellationToken ct) + { + var uid = cq.Data != null && cq.Data.Length > 7 ? cq.Data.Substring(7) : ""; + var tgId = cq.From.Id.ToString(); + var name = _displayName; + + var user = await _api.GetUserByTelegramAsync(tgId, ct).ConfigureAwait(false); + if (user == null || !user.found) + { + await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false); + await bot.EditMessageText(cq.Message!.Chat.Id, cq.Message.MessageId, $"Тебя нет в базе {name}.", cancellationToken: ct).ConfigureAwait(false); + return; + } + + var devicesResp = await _api.GetDevicesAsync(tgId, ct).ConfigureAwait(false); + var devices = devicesResp?.devices ?? new List(); + var uids = devices.Select(d => d.uid).Where(u => !string.IsNullOrEmpty(u)).ToHashSet(StringComparer.OrdinalIgnoreCase); + if (!uids.Contains(uid)) + { + await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false); + await bot.EditMessageText(cq.Message.Chat.Id, cq.Message.MessageId, "Это устройство не принадлежит тебе.", cancellationToken: ct).ConfigureAwait(false); + return; + } + + var ok = await _api.UnbindDeviceAsync(tgId, uid, ct).ConfigureAwait(false); + await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false); + if (ok) + await bot.EditMessageText(cq.Message.Chat.Id, cq.Message.MessageId, $"Устройство {uid} отвязано.", cancellationToken: ct).ConfigureAwait(false); + else + await bot.EditMessageText(cq.Message.Chat.Id, cq.Message.MessageId, "Не удалось отвязать устройство.", cancellationToken: ct).ConfigureAwait(false); + } + + async Task HandleReactivateDeviceCallbackAsync(ITelegramBotClient bot, CallbackQuery cq, CancellationToken ct) + { + var prefixLen = CbReactivateDevice.Length; + var uid = cq.Data != null && cq.Data.Length > prefixLen ? cq.Data.Substring(prefixLen) : ""; + var tgId = cq.From.Id.ToString(); + var name = _displayName; + + var user = await _api.GetUserByTelegramAsync(tgId, ct).ConfigureAwait(false); + if (user == null || !user.found) + { + await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false); + await bot.EditMessageText(cq.Message!.Chat.Id, cq.Message.MessageId, $"Тебя нет в базе {name}.", cancellationToken: ct).ConfigureAwait(false); + return; + } + + var devicesResp = await _api.GetDevicesAsync(tgId, ct).ConfigureAwait(false); + var devices = devicesResp?.devices ?? new List(); + var uids = devices.Select(d => d.uid).Where(u => !string.IsNullOrEmpty(u)).ToHashSet(StringComparer.OrdinalIgnoreCase); + if (!uids.Contains(uid)) + { + await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false); + await bot.EditMessageText(cq.Message.Chat.Id, cq.Message.MessageId, "Это устройство не принадлежит тебе.", cancellationToken: ct).ConfigureAwait(false); + return; + } + + var (ok, detail) = await _api.ReactivateDeviceAsync(tgId, uid, ct).ConfigureAwait(false); + await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false); + if (ok) + await bot.EditMessageText(cq.Message.Chat.Id, cq.Message.MessageId, $"Устройство {EscapeHtml(uid)} снова активно. Открой приложение и при необходимости нажми «Проверить снова».", parseMode: ParseMode.Html, cancellationToken: ct).ConfigureAwait(false); + else + await bot.EditMessageText(cq.Message.Chat.Id, cq.Message.MessageId, "Не удалось включить устройство.\n" + TruncateForTelegram(StripJsonError(detail), 500), cancellationToken: ct).ConfigureAwait(false); + } + + async Task HandlePendingRegistrationCallbackAsync(ITelegramBotClient bot, CallbackQuery cq, CancellationToken ct) + { + if (!await TryEnsureAdminForCallbackAsync(bot, cq, ct).ConfigureAwait(false)) + { + await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false); + return; + } + + var data = cq.Data ?? ""; + var approve = data.StartsWith(CbApprovePending, StringComparison.Ordinal); + if (!approve && !data.StartsWith(CbRejectPending, StringComparison.Ordinal)) + { + await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false); + return; + } + + var prefixLen = approve ? CbApprovePending.Length : CbRejectPending.Length; + var rest = data.Length > prefixLen ? data.Substring(prefixLen) : ""; + var parts = rest.Split('|', 2); + if (parts.Length != 2 || !int.TryParse(parts[0], out var returnPage) || returnPage < 0 || string.IsNullOrWhiteSpace(parts[1])) + { + await bot.AnswerCallbackQuery(cq.Id, "Некорректные данные кнопки.", showAlert: true, cancellationToken: ct).ConfigureAwait(false); + return; + } + + var targetId = parts[1].Trim(); + var (ok, detail) = await _api.ResolveRegistrationPendingAsync(targetId, approve, ct).ConfigureAwait(false); + if (!ok) + { + await bot.AnswerCallbackQuery(cq.Id, TruncateForTelegram(StripJsonError(detail), 180), showAlert: true, cancellationToken: ct).ConfigureAwait(false); + return; + } + + var msg = cq.Message; + var actorTgId = cq.From.Id.ToString(); + if (msg != null && msg.Text != null && msg.Text.IndexOf("Пользователи TelegramAuth", StringComparison.Ordinal) >= 0) + { + var list = await _api.GetAdminUsersAsync(ct).ConfigureAwait(false); + if (list != null && list.ok) + await SendOrEditAdminUsersPageAsync(bot, msg.Chat.Id, msg.MessageId, list, returnPage, actorTgId, ct).ConfigureAwait(false); + } + else if (msg != null) + { + var suffix = approve + ? "\n\n✅ Подтверждено — доступ включён." + : "\n\n❌ Отклонено — запись пользователя удалена из базы."; + try + { + await bot.EditMessageText(msg.Chat.Id, msg.MessageId, msg.Text + suffix, parseMode: ParseMode.Html, replyMarkup: null, cancellationToken: ct).ConfigureAwait(false); + } + catch + { + } + } + + await bot.AnswerCallbackQuery(cq.Id, approve ? "Доступ подтверждён." : "Запись удалена.", cancellationToken: ct).ConfigureAwait(false); + } + + async Task HandleAdminUsersInlineAsync(ITelegramBotClient bot, CallbackQuery cq, CancellationToken ct) + { + if (!await TryEnsureAdminForCallbackAsync(bot, cq, ct).ConfigureAwait(false)) + { + await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false); + return; + } + + var msg = cq.Message; + if (msg == null) + { + await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false); + return; + } + + var chatId = msg.Chat.Id; + var msgId = msg.MessageId; + var actorTgId = cq.From.Id.ToString(); + var data = cq.Data ?? ""; + + if (data.StartsWith(CbUsersPage, StringComparison.Ordinal)) + { + if (!int.TryParse(data.AsSpan(CbUsersPage.Length), out var page) || page < 0) + page = 0; + var list = await _api.GetAdminUsersAsync(ct).ConfigureAwait(false); + if (list == null || !list.ok) + { + await bot.AnswerCallbackQuery(cq.Id, "Не удалось обновить список.", showAlert: true, cancellationToken: ct).ConfigureAwait(false); + return; + } + + await SendOrEditAdminUsersPageAsync(bot, chatId, msgId, list, page, actorTgId, ct).ConfigureAwait(false); + await bot.AnswerCallbackQuery(cq.Id, cancellationToken: ct).ConfigureAwait(false); + return; + } + + if (data.StartsWith(CbDisableUser, StringComparison.Ordinal) || data.StartsWith(CbEnableUser, StringComparison.Ordinal)) + { + var disable = data.StartsWith(CbDisableUser, StringComparison.Ordinal); + var rest = disable ? data.Substring(CbDisableUser.Length) : data.Substring(CbEnableUser.Length); + var parts = rest.Split('|', 2); + if (parts.Length != 2 || !int.TryParse(parts[0], out var returnPage) || returnPage < 0 || string.IsNullOrWhiteSpace(parts[1])) + { + await bot.AnswerCallbackQuery(cq.Id, "Некорректные данные кнопки.", showAlert: true, cancellationToken: ct).ConfigureAwait(false); + return; + } + + var targetId = parts[1].Trim(); + if (disable && string.Equals(targetId, actorTgId, StringComparison.Ordinal)) + { + await bot.AnswerCallbackQuery(cq.Id, "Нельзя отключить самого себя.", showAlert: true, cancellationToken: ct).ConfigureAwait(false); + return; + } + + var (ok, detail) = await _api.SetUserDisabledAsync(targetId, disable, ct).ConfigureAwait(false); + if (!ok) + { + await bot.AnswerCallbackQuery(cq.Id, TruncateForTelegram(StripJsonError(detail), 180), showAlert: true, cancellationToken: ct).ConfigureAwait(false); + return; + } + + var fresh = await _api.GetAdminUsersAsync(ct).ConfigureAwait(false); + if (fresh != null && fresh.ok) + await SendOrEditAdminUsersPageAsync(bot, chatId, msgId, fresh, returnPage, actorTgId, ct).ConfigureAwait(false); + + await bot.AnswerCallbackQuery(cq.Id, disable ? "Доступ отключён." : "Доступ включён.", showAlert: false, cancellationToken: ct).ConfigureAwait(false); + } + } + } +} diff --git a/mods/TelegramAuthBot/Services/TelegramAuthBotSession.Text.cs b/mods/TelegramAuthBot/Services/TelegramAuthBotSession.Text.cs new file mode 100644 index 0000000..f5341e5 --- /dev/null +++ b/mods/TelegramAuthBot/Services/TelegramAuthBotSession.Text.cs @@ -0,0 +1,41 @@ +namespace TelegramAuthBot.Services +{ + sealed partial class TelegramAuthBotSession + { + static string TruncateForTelegram(string s, int maxLen) + { + if (string.IsNullOrEmpty(s) || s.Length <= maxLen) + return s ?? ""; + return s.Substring(0, maxLen) + "…"; + } + + static string StripJsonError(string json) + { + if (string.IsNullOrEmpty(json)) + return "Ошибка API"; + try + { + var jo = JObject.Parse(json); + var err = jo.Value("error"); + var det = jo.Value("detail"); + if (!string.IsNullOrEmpty(det)) + return det; + return err ?? json; + } + catch + { + return json; + } + } + + static string EscapeHtml(string s) + { + if (string.IsNullOrEmpty(s)) + return ""; + return s + .Replace("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal); + } + } +} diff --git a/mods/TelegramAuthBot/Services/TelegramAuthBotSession.UserFlow.cs b/mods/TelegramAuthBot/Services/TelegramAuthBotSession.UserFlow.cs new file mode 100644 index 0000000..b073660 --- /dev/null +++ b/mods/TelegramAuthBot/Services/TelegramAuthBotSession.UserFlow.cs @@ -0,0 +1,302 @@ +using Telegram.Bot.Types; +using TelegramAuthBot.Models; + +namespace TelegramAuthBot.Services +{ + sealed partial class TelegramAuthBotSession + { + async Task NotifyAdminsOfPendingProvisionAsync(ITelegramBotClient bot, string newUserTgId, string username, string deviceUid, CancellationToken ct) + { + var conf = ModInit.conf; + if (!conf.notify_admins_on_pending_provision) + return; + + if (string.IsNullOrEmpty(conf.mutations_api_secret?.Trim())) + { + TelegramAuthBotSerilog.Log.Warning( + "Новый пользователь ожидает активации (TelegramId={TgId}), mutations_api_secret пуст — уведомления админам не отправлены.", + newUserTgId); + return; + } + + AdminUsersListResponseDto data; + try + { + data = await _api.GetAdminUsersAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) + { + TelegramAuthBotSerilog.Log.Warning(ex, "Не удалось загрузить список пользователей для уведомления о новом аккаунте"); + return; + } + + if (data?.users == null || data.users.Count == 0) + return; + + var admins = data.users + .Where(u => string.Equals(u.role, "admin", StringComparison.OrdinalIgnoreCase)) + .Where(u => !string.IsNullOrWhiteSpace(u.telegramId)) + .ToList(); + + if (admins.Count == 0) + { + TelegramAuthBotSerilog.Log.Warning( + "Новый пользователь ожидает активации (TelegramId={TgId}), в базе нет пользователей с ролью admin.", + newUserTgId); + return; + } + + var at = string.IsNullOrEmpty(username) ? "—" : "@" + EscapeHtml(username); + var text = + "🔔 Новый пользователь (ожидает решения)\n\n" + + $"Telegram ID: {EscapeHtml(newUserTgId)}\n" + + $"Username: {at}\n" + + $"UID устройства: {EscapeHtml(deviceUid)}\n\n" + + "Подтвердить — включить доступ. Отклонить — удалить запись из базы."; + + var cbApprove = CbApprovePending + "0|" + newUserTgId; + var cbReject = CbRejectPending + "0|" + newUserTgId; + InlineKeyboardMarkup notifyKb = null; + if (Encoding.UTF8.GetByteCount(cbApprove) <= 64 && Encoding.UTF8.GetByteCount(cbReject) <= 64) + { + notifyKb = new InlineKeyboardMarkup(new[] + { + new[] + { + InlineKeyboardButton.WithCallbackData("✅ Принять", cbApprove), + InlineKeyboardButton.WithCallbackData("❌ Отклонить", cbReject) + } + }); + } + + foreach (var a in admins) + { + if (!long.TryParse(a.telegramId.Trim(), out var adminChatId)) + continue; + try + { + await bot.SendMessage(new ChatId(adminChatId), text, parseMode: ParseMode.Html, replyMarkup: notifyKb, cancellationToken: ct).ConfigureAwait(false); + } + catch (Exception ex) + { + TelegramAuthBotSerilog.Log.Warning(ex, "Не удалось отправить уведомление администратору {AdminTgId}", a.telegramId); + } + } + } + + async Task TryBindAsync(ITelegramBotClient bot, ChatId chatId, string tgId, string username, string uid, bool fromStartDeepLink, CancellationToken ct) + { + var name = _displayName; + var user = await _api.GetUserByTelegramAsync(tgId, ct).ConfigureAwait(false); + if (user != null && user.found && !user.active) + { + string blocked; + if (user.registrationPending) + blocked = "Аккаунт ожидает подтверждения администратора. Когда доступ подтвердят, нажми «Проверить снова» в приложении."; + else if (user.disabled) + blocked = "Доступ отключён администратором. Если только что отправил UID, дождись включения и снова нажми «Проверить снова» в приложении."; + else + blocked = "Твой доступ истёк."; + await bot.SendMessage(chatId, blocked, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false); + return true; + } + + var bind = await _api.BindCompleteAsync(uid, tgId, username, ct).ConfigureAwait(false); + if (bind.Ok) + { + if (bind.PendingAdminApproval) + await NotifyAdminsOfPendingProvisionAsync(bot, tgId, username, uid, ct).ConfigureAwait(false); + + string userText; + if (bind.PendingAdminApproval) + { + userText = + $"✅ Запрос принят\n\n{EscapeHtml(uid)}\n\n" + + "Аккаунт создан и ждёт подтверждения администратора (уведомление с кнопками «Принять» / «Отклонить» или список /users).\n\n" + + $"После подтверждения вернись в {EscapeHtml(name)} и нажми «Проверить снова»."; + } + else + { + var extra = fromStartDeepLink + ? "" + : "\n\n💡 Подсказка: кнопкой 📱 Мои устройства можно посмотреть и отвязать устройства."; + userText = + $"✅ Устройство привязано\n\n{EscapeHtml(uid)}\n\n" + + $"Вернись в {EscapeHtml(name)} и нажми «Проверить снова».{extra}"; + } + + await bot.SendMessage(chatId, userText, parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false); + return true; + } + + if (fromStartDeepLink) + return false; + + if (user != null && user.found) + await bot.SendMessage(chatId, "Не удалось привязать устройство.", replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false); + else + await bot.SendMessage(chatId, $"Тебя нет в базе {EscapeHtml(name)}. Обратись к администратору.", parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false); + return true; + } + + async Task CmdMeAsync(ITelegramBotClient bot, ChatId chatId, string tgId, CancellationToken ct) + { + var name = _displayName; + var data = await _api.GetUserByTelegramAsync(tgId, ct).ConfigureAwait(false); + if (data == null || !data.found) + { + await bot.SendMessage(chatId, $"Тебя нет в базе авторизации {EscapeHtml(name)}.", parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false); + return; + } + + var maxDev = data.maxDevices == -1 ? "∞" : data.maxDevices.ToString(); + var expires = string.IsNullOrEmpty(data.expiresAt) ? "-" : data.expiresAt; + var accessNote = data.registrationPending + ? " (ожидает подтверждения)" + : data.disabled ? " (отключён администратором)" : ""; + var text = + $"Профиль {EscapeHtml(name)}\n\n" + + $"Пользователь: @{EscapeHtml(data.username ?? "-")}\n" + + $"Telegram ID: {EscapeHtml(data.telegramId ?? tgId)}\n" + + $"Роль: {EscapeHtml(data.role ?? "-")}\n" + + $"Язык: {EscapeHtml(data.lang ?? "-")}\n" + + $"Активен: {(data.active ? "да" : "нет")}{accessNote}\n" + + $"Срок доступа: {EscapeHtml(expires)}\n" + + $"Устройств: {data.deviceCount} / {maxDev}"; + await bot.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false); + } + + async Task CmdDevicesAsync(ITelegramBotClient bot, ChatId chatId, string tgId, CancellationToken ct) + { + var data = await _api.GetDevicesAsync(tgId, ct).ConfigureAwait(false); + var devices = data?.devices ?? new List(); + if (devices.Count == 0) + { + await bot.SendMessage(chatId, "У тебя пока нет привязанных устройств.", cancellationToken: ct).ConfigureAwait(false); + return; + } + + var lines = new List { $"Устройства @{EscapeHtml(data!.username ?? tgId)}:" }; + var keyboard = new List(); + foreach (var d in devices) + { + var uid = d.uid ?? ""; + var devName = string.IsNullOrEmpty(d.name) ? "без имени" : d.name; + var state = d.active ? "активно" : "отключено"; + lines.Add($"• {EscapeHtml(uid)} — {EscapeHtml(devName)} ({state})"); + if (!string.IsNullOrEmpty(uid) && d.active) + keyboard.Add(new[] { InlineKeyboardButton.WithCallbackData($"Отвязать {devName}", "unbind:" + uid) }); + else if (!string.IsNullOrEmpty(uid) && !d.active) + keyboard.Add(new[] { InlineKeyboardButton.WithCallbackData($"✅ Включить · {devName}", CbReactivateDevice + uid) }); + } + + var markup = keyboard.Count > 0 ? new InlineKeyboardMarkup(keyboard) : null; + await bot.SendMessage(chatId, string.Join("\n", lines), parseMode: ParseMode.Html, replyMarkup: markup, cancellationToken: ct).ConfigureAwait(false); + } + + async Task CmdDeviceNameAsync(ITelegramBotClient bot, ChatId chatId, string tgId, string text, CancellationToken ct) + { + var t = text.Trim(); + var cmdLen = "/devicename".Length; + if (t.Length > cmdLen && t[cmdLen] == '@') + { + var at = t.IndexOf(' ', cmdLen); + if (at < 0) + { + await bot.SendMessage(chatId, + "Переименование устройства\n\n/devicename <uid> <имя>\n\nUID из «Мои устройства». Чтобы сбросить имя: вместо имени отправь -.", + parseMode: ParseMode.Html, + replyMarkup: MainMenuKeyboard(), + cancellationToken: ct).ConfigureAwait(false); + return; + } + + cmdLen = at; + } + + var args = t.Length > cmdLen ? t.Substring(cmdLen).TrimStart() : ""; + if (string.IsNullOrEmpty(args)) + { + await bot.SendMessage(chatId, + "Переименование устройства\n\n/devicename <uid> <имя>\n\nПример: /devicename abc12xyz Телевизор в зале\nСброс имени: /devicename abc12xyz -", + parseMode: ParseMode.Html, + replyMarkup: MainMenuKeyboard(), + cancellationToken: ct).ConfigureAwait(false); + return; + } + + var firstSpace = args.IndexOf(' '); + if (firstSpace < 0) + { + await bot.SendMessage(chatId, "Нужны UID и имя. Пример: /devicename abc12xyz Телевизор", parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false); + return; + } + + var uid = args.Substring(0, firstSpace).Trim(); + var newName = args.Substring(firstSpace + 1).Trim(); + if (string.IsNullOrEmpty(newName)) + { + await bot.SendMessage(chatId, "Укажи новое имя после UID или - для сброса.", parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false); + return; + } + + if (!UidRe.IsMatch(uid)) + { + await bot.SendMessage(chatId, "Некорректный UID.", replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false); + return; + } + + var devicesResp = await _api.GetDevicesAsync(tgId, ct).ConfigureAwait(false); + var devices = devicesResp?.devices ?? new List(); + var owned = devices.Any(d => + d != null + && d.active + && !string.IsNullOrEmpty(d.uid) + && string.Equals(d.uid, uid, StringComparison.OrdinalIgnoreCase)); + if (!owned) + { + await bot.SendMessage(chatId, "Активного устройства с таким UID у тебя нет. Список — в «Мои устройства».", replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false); + return; + } + + string apiName = string.Equals(newName, "-", StringComparison.Ordinal) ? null : newName; + var (ok, detail) = await _api.SetDeviceDisplayNameAsync(uid, apiName, ct).ConfigureAwait(false); + if (ok) + { + var shown = apiName == null ? "сброшено (без имени)" : $"«{EscapeHtml(apiName)}»"; + await bot.SendMessage(chatId, $"✅ Имя устройства {EscapeHtml(uid)} — {shown}.", parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false); + } + else + { + await bot.SendMessage(chatId, + "❌ Не удалось сохранить имя. Если доступ истёк или устройство неактивно — привяжи снова.\n" + TruncateForTelegram(detail, 500), + replyMarkup: MainMenuKeyboard(), + cancellationToken: ct).ConfigureAwait(false); + } + } + + async Task CmdHelpAsync(ITelegramBotClient bot, ChatId chatId, CancellationToken ct) + { + var name = _displayName; + var conf = ModInit.conf; + var text = + $"❓ Помощь по входу в {EscapeHtml(name)}\n\n" + + "Быстрый вход:\n" + + $"1. Открой {EscapeHtml(name)} и дойди до экрана авторизации\n" + + "2. Скопируй UID устройства\n" + + "3. Отправь UID мне сюда\n" + + "4. Вернись в " + EscapeHtml(name) + " и нажми «Проверить снова»\n\n" + + "Кнопки:\n" + + "👤 Мой статус — профиль и срок доступа\n" + + "📱 Мои устройства — список; отвязать активные, включить отключённые\n" + + "/devicename <uid> <имя> — имя в базе (для админки); - сбрасывает\n" + + "❓ Помощь — эта подсказка\n\n" + + "Владелец: числовой user id в TelegramAuth.owner_telegram_ids — при старте Lampac запись admin создаётся в базе. Остальные шлют UID; новых пользователей подтверждаешь в /users (Принять / Отклонить) или кнопками в уведомлении.\n\n" + + "Админ-команды: /users, /user <id>, /setuser …, /import, /cleanup + одинаковый mutations_api_secret." + + (conf.admin_chat_ids != null && conf.admin_chat_ids.Length > 0 + ? "\n\nadmin_chat_ids задан: команды из группы — только там; из лички — если твой id в owner_telegram_ids бота (как на сервере)." + : "\n\nПустой admin_chat_ids — админ-команды из лички без доп. списков."); + await bot.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false); + } + } +} diff --git a/mods/TelegramAuthBot/Services/TelegramAuthBotSession.cs b/mods/TelegramAuthBot/Services/TelegramAuthBotSession.cs new file mode 100644 index 0000000..f9825d5 --- /dev/null +++ b/mods/TelegramAuthBot/Services/TelegramAuthBotSession.cs @@ -0,0 +1,244 @@ +using Telegram.Bot.Types; +using TelegramAuthBot.Models; + +namespace TelegramAuthBot.Services +{ + sealed partial class TelegramAuthBotSession + { + static readonly Regex UidRe = new(@"^[a-zA-Z0-9_-]{6,20}$", RegexOptions.Compiled); + static readonly Regex StartCommandRe = new(@"^/start(?:@\w+)?(?:\s+(\S+))?\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + static readonly Regex AdminUserDetailRe = new(@"^/user(?:@\w+)?\s+(\d+)\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + static readonly Regex AdminSetUserRe = new(@"^/setuser(?:@\w+)?\s+(\d+)\s+([a-zA-Z_]+)\s*(.*)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + const string BtnStatus = "👤 Мой статус"; + const string BtnDevices = "📱 Мои устройства"; + const string BtnHelp = "❓ Помощь"; + + const int AdminUsersPageSize = 8; + const string CbUsersPage = "ulp:"; + const string CbDisableUser = "d|"; + const string CbEnableUser = "e|"; + const string CbApprovePending = "ap|"; + const string CbRejectPending = "rp|"; + const string CbReactivateDevice = "react:"; + + readonly LampacTelegramAuthHttpClient _api; + readonly string _displayName; + int _firstUpdateLogged; + + public TelegramAuthBotSession(LampacTelegramAuthHttpClient api, string displayName) + { + _api = api; + _displayName = displayName; + } + + public async Task HandleUpdateAsync(ITelegramBotClient bot, Update update, CancellationToken ct) + { + if (Interlocked.CompareExchange(ref _firstUpdateLogged, 1, 0) == 0) + TelegramAuthBotSerilog.Log.Information("Первый апдейт {UpdateId}", update.Id); + + if (update.CallbackQuery is { } cq) + { + await HandleCallbackAsync(bot, cq, ct).ConfigureAwait(false); + return; + } + + var msg = update.Message ?? update.EditedMessage; + if (msg is not { } m) + return; + + var text = MessageTextOrCaption(m); + if (string.IsNullOrEmpty(text)) + return; + + if (!TryResolveTelegramUserId(m, out var tgId)) + { + TelegramAuthBotSerilog.Log.Warning("Не удалось определить telegram user id UpdateId={UpdateId} ChatType={ChatType}", update.Id, m.Chat.Type); + await bot.SendMessage(m.Chat.Id, + "Не могу определить твой Telegram ID в этом чате. Напиши боту в личные сообщения.", + cancellationToken: ct).ConfigureAwait(false); + return; + } + + await HandleMessageAsync(bot, m, text, tgId, ct).ConfigureAwait(false); + } + + static string MessageTextOrCaption(Message msg) + { + if (!string.IsNullOrEmpty(msg.Text)) + return msg.Text; + if (!string.IsNullOrEmpty(msg.Caption)) + return msg.Caption; + return null; + } + + static bool TryResolveTelegramUserId(Message msg, out string tgId) + { + if (msg.From != null) + { + tgId = msg.From.Id.ToString(); + return true; + } + + if (msg.Chat.Type == ChatType.Private) + { + tgId = msg.Chat.Id.ToString(); + return true; + } + + tgId = ""; + return false; + } + + static ReplyKeyboardMarkup MainMenuKeyboard() => + new(new[] + { + new KeyboardButton[] { new(BtnStatus), new(BtnDevices) }, + new KeyboardButton[] { new(BtnHelp) } + }) + { + ResizeKeyboard = true + }; + + async Task SendStartText(ITelegramBotClient bot, ChatId chatId, CancellationToken ct) + { + var name = _displayName; + var text = + $"✨ Привет. Я бот авторизации {EscapeHtml(name)}.\n\n" + + $"С моей помощью можно быстро войти в {EscapeHtml(name)} через Telegram.\n\n" + + "Что я умею:\n" + + "• 🔗 привязать устройство по UID\n" + + "• 👤 показать твой статус\n" + + "• 📱 показать список устройств и переименовать (/devicename)\n" + + "• 🗑️ отвязать устройство кнопкой\n\n" + + "Как войти:\n" + + $"1. Открой {EscapeHtml(name)}\n" + + "2. Скопируй UID с экрана авторизации\n" + + "3. Отправь его мне\n" + + "4. Вернись в " + EscapeHtml(name) + " и нажми «Проверить снова»\n\n" + + "Или просто используй кнопки ниже 👇"; + await bot.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false); + } + + async Task HandleMessageAsync(ITelegramBotClient bot, Message msg, string text, string tgId, CancellationToken ct) + { + var chatId = msg.Chat.Id; + var username = msg.From?.Username ?? ""; + + if (text.StartsWith("/start", StringComparison.OrdinalIgnoreCase)) + { + var m = StartCommandRe.Match(text.Trim()); + var deepUid = m.Success && m.Groups[1].Success ? m.Groups[1].Value.Trim() : ""; + if (!string.IsNullOrEmpty(deepUid) && UidRe.IsMatch(deepUid)) + { + var handled = await TryBindAsync(bot, chatId, tgId, username, deepUid, fromStartDeepLink: true, ct).ConfigureAwait(false); + if (handled) + return; + } + + await SendStartText(bot, chatId, ct).ConfigureAwait(false); + return; + } + + if (IsCommand(text, "/help")) + { + await CmdHelpAsync(bot, chatId, ct).ConfigureAwait(false); + return; + } + + if (IsCommand(text, "/me")) + { + await CmdMeAsync(bot, chatId, tgId, ct).ConfigureAwait(false); + return; + } + + if (IsCommand(text, "/devices")) + { + await CmdDevicesAsync(bot, chatId, tgId, ct).ConfigureAwait(false); + return; + } + + if (IsCommand(text, "/devicename")) + { + await CmdDeviceNameAsync(bot, chatId, tgId, text, ct).ConfigureAwait(false); + return; + } + + if (IsCommand(text, "/import")) + { + await CmdImportAsync(bot, msg.Chat, tgId, ct).ConfigureAwait(false); + return; + } + + if (IsCommand(text, "/cleanup")) + { + await CmdCleanupAsync(bot, msg.Chat, tgId, ct).ConfigureAwait(false); + return; + } + + if (IsCommand(text, "/users")) + { + await CmdUsersAsync(bot, msg.Chat, tgId, ct).ConfigureAwait(false); + return; + } + + if (IsCommand(text, "/user")) + { + await CmdAdminUserDetailAsync(bot, msg.Chat, tgId, text, ct).ConfigureAwait(false); + return; + } + + if (IsCommand(text, "/setuser")) + { + await CmdAdminSetUserAsync(bot, msg.Chat, tgId, text, ct).ConfigureAwait(false); + return; + } + + var trimmed = text.Trim(); + if (trimmed == BtnStatus) + { + await CmdMeAsync(bot, chatId, tgId, ct).ConfigureAwait(false); + return; + } + + if (trimmed == BtnDevices) + { + await CmdDevicesAsync(bot, chatId, tgId, ct).ConfigureAwait(false); + return; + } + + if (trimmed == BtnHelp) + { + await CmdHelpAsync(bot, chatId, ct).ConfigureAwait(false); + return; + } + + if (trimmed.StartsWith('/')) + return; + + if (UidRe.IsMatch(trimmed)) + { + await TryBindAsync(bot, chatId, tgId, username, trimmed, fromStartDeepLink: false, ct).ConfigureAwait(false); + return; + } + + var name = _displayName; + await bot.SendMessage(chatId, + $"Я жду UID устройства из {EscapeHtml(name)} или используй кнопки ниже 👇", + parseMode: ParseMode.Html, + replyMarkup: MainMenuKeyboard(), + cancellationToken: ct).ConfigureAwait(false); + } + + static bool IsCommand(string text, string command) + { + var t = text.Trim(); + if (!t.StartsWith(command, StringComparison.OrdinalIgnoreCase)) + return false; + if (t.Length == command.Length) + return true; + var c = t[command.Length]; + return c == ' ' || c == '@'; + } + } +} diff --git a/mods/TelegramAuthBot/TelegramAuthBot.csproj b/mods/TelegramAuthBot/TelegramAuthBot.csproj new file mode 100644 index 0000000..34be245 --- /dev/null +++ b/mods/TelegramAuthBot/TelegramAuthBot.csproj @@ -0,0 +1,24 @@ + + + net10.0 + library + true + TelegramAuthBot + enable + disable + + + + + + + + + + <_TelegramBotRef Include="@(ReferencePath)" Condition="'%(Filename)' == 'Telegram.Bot'" /> + + + + + diff --git a/mods/TelegramAuthBot/init.merge.example.json b/mods/TelegramAuthBot/init.merge.example.json new file mode 100644 index 0000000..b3bce7f --- /dev/null +++ b/mods/TelegramAuthBot/init.merge.example.json @@ -0,0 +1,13 @@ +{ + "TelegramAuthBot": { + "enable": true, + "bot_token": "PASTE_YOUR_TELEGRAM_BOT_TOKEN_HERE", + "lampac_base_url": "http://127.0.0.1:9118", + "request_timeout_sec": 10, + "service_display_name": "Lampac NextGen Bot", + "mutations_api_secret": "SAME_AS_TelegramAuth.mutations_api_secret", + "admin_chat_ids": [], + "owner_telegram_ids": [], + "notify_admins_on_pending_provision": true + } +} diff --git a/mods/TelegramAuthBot/manifest.json b/mods/TelegramAuthBot/manifest.json new file mode 100644 index 0000000..8f75ac8 --- /dev/null +++ b/mods/TelegramAuthBot/manifest.json @@ -0,0 +1,13 @@ +{ + "enable": true, + "dynamic": true, + "references": [ + "references/" + ], + "tree": [ + "Models", + "Services", + "GlobalUsings.cs", + "ModInit.cs" + ] +} \ No newline at end of file diff --git a/mods/TelegramAuthBot/references/Telegram.Bot.dll b/mods/TelegramAuthBot/references/Telegram.Bot.dll new file mode 100755 index 0000000..addba08 Binary files /dev/null and b/mods/TelegramAuthBot/references/Telegram.Bot.dll differ diff --git a/mods/WatchTogether/ModInit.cs b/mods/WatchTogether/ModInit.cs new file mode 100644 index 0000000..00fc54e --- /dev/null +++ b/mods/WatchTogether/ModInit.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.DependencyInjection; +using Shared; +using Shared.Models.AppConf; +using Shared.Models.Events; +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; +using System.Collections.Generic; +using Shared.Services; + +namespace WatchTogether +{ + public class WatchTogetherConf + { + public bool enable { get; set; } = true; + public bool allow_anonymous { get; set; } = true; + public int relay_max_channels { get; set; } = 500; + public int relay_max_clients_per_channel { get; set; } = 32; + public int gc_empty_timeout_minutes { get; set; } = 30; + public int gc_max_lifetime_hours { get; set; } = 12; + + public List limit_map { get; set; } = new() + { + new("^/watchtogether/", new WafLimitMap { limit = 30, second = 1 }), + new("^/wt/c/", new WafLimitMap { limit = 10, second = 1 }) + }; + } + + /// + /// WatchTogether: a lampac module that acts as both the lparty relay and a + /// shadow host keeping rooms alive when their creator disconnects. + /// + public class ModInit : IModuleLoaded, IModuleConfigure + { + public static string modpath; + public static WatchTogetherConf conf; + + public void Configure(ConfigureModel app) + { + app.services.AddTransient(); + } + + public void Loaded(InitspaceModel baseconf) + { + modpath = baseconf.path; + UpdateConf(); + EventListener.UpdateInitFile += UpdateConf; + ShadowHost.Start(); + } + + public void Dispose() + { + EventListener.UpdateInitFile -= UpdateConf; + ShadowHost.Stop(); + } + + private static bool isWafInjected = false; + + private void UpdateConf() + { + conf = ModuleInvoke.Init("WatchTogether", new WatchTogetherConf()); + + if (!isWafInjected && conf.limit_map != null) + { + foreach (var m in conf.limit_map) + { + CoreInit.conf.WAF.limit_map.Insert(0, m); + } + isWafInjected = true; + } + } + } +} diff --git a/mods/WatchTogether/README.md b/mods/WatchTogether/README.md new file mode 100644 index 0000000..49e74e9 --- /dev/null +++ b/mods/WatchTogether/README.md @@ -0,0 +1,44 @@ +# WatchTogether + +Модуль синхронизированного просмотра для Lampac. Работает одновременно как **хранитель состояния** и как **тихий lparty-клиент**: комната живёт в канале реле, а сервер незаметно подхватывает роль хоста, если создатель отключился. + +Клиентский плагин основан на [lparty.js](https://nrsua.github.io/lampa-nrs/lparty.js) (nrsua, levende) и полностью wire-совместим с его экосистемой — JustPlus Player, lparty-web и стоковый lparty.js подключаются к тем же комнатам без правок. + +## Как это работает + +1. **Создание комнаты.** Человек создаёт комнату в любом lparty-клиенте — его клиент открывает канал `wss:///wt/c/lparty-r-` и является хостом. +2. **Тень.** Сервер видит адрес подключения и начинает пассивно слушать кадры канала: копирует url, метаданные, позицию, скорость и список участников. В списке зрителей он виден как `Lampac`, но ничего не рассылает. +3. **Подмена.** Как только соединение хоста рвётся, сервер мгновенно шлёт кадр `host` и продолжает timeline с последнего хартбита (лаг ≤ 2с, пауза — точно). Попытки других клиентов перехватить хостство отклоняются повторным `host=сервер`. +4. **Возврат.** Создатель переподключился — сервер отдаёт ему роль и снова уходит в тень. +5. **GC.** Пустые комнаты удаляются через `gc_empty_timeout_minutes`, любые — через `gc_max_lifetime_hours`. + +## HTTP (обзор) + +| Маршрут | Описание | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `GET /watchtogether.js` | Плагин для клиента Lampa (адаптированный lparty.js). | +| `GET /watch_together/` | Веб-плеер для анонимных гостей (адаптированный lparty-web). Поддерживает `?room=` и `?relay=`. | +| `WS /wt/c/{channel}` | LParty-совместимый релей: каналы, кадры join/leave/message, эхо с серверным временем. | + +## Скорость воспроизведения + +Наши клиенты передают скорость поле `sp` в кадрах `state`/`sync`/`act`/`hold`/`go` и применяют её к `playbackRate`. Стоковые клиенты поле молча игнорируют — обратная совместимость не нарушается. Если автор JustPlus добавит поддержку `sp`, синхронизация заработает у него автоматически. + +## Конфигурация + +Секция в `init.conf`: **`WatchTogether`**. + +| Поле | Смысл | +| ------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `enable` | Включение модуля (по умолчанию **true**). | +| `allow_anonymous` | Доступ к плагину `/watchtogether.js` и веб-плееру lparty-web без авторизации (по умолчанию **true**). | +| `relay_max_channels` | Лимит одновременных каналов реле (по умолчанию **500**). | +| `relay_max_clients_per_channel` | Лимит участников одного канала (по умолчанию **32**). | +| `gc_empty_timeout_minutes` | Удаление пустой комнаты через N минут (по умолчанию **30**). | +| `gc_max_lifetime_hours` | Удаление любой комнаты через N часов (по умолчанию **12**). | + +## Подключение клиентов + +- Плагин Lampa: `/watchtogether.js` — реле уже настроено на собственный сервер lampac. +- JustPlus Player / lparty.js: в настройках клиента указать адрес реле `ws(s)://<адрес-lampac>/wt/c/`. +- Веб-гость: открыть `https:///watch_together/?room=` — ссылку формирует плагин. diff --git a/mods/WatchTogether/RelayServer.cs b/mods/WatchTogether/RelayServer.cs new file mode 100644 index 0000000..a1c7e10 --- /dev/null +++ b/mods/WatchTogether/RelayServer.cs @@ -0,0 +1,456 @@ +using Core.Middlewares; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Net.WebSockets; +using System.Text.Json; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace WatchTogether +{ + /// + /// Maps the lparty-compatible relay endpoint (/wt/c/{channel}) at app startup. + /// Modules cannot map endpoints themselves, so this hooks the pipeline + /// through an IStartupFilter registered in ModInit.Configure. The branch + /// runs before the global pipeline (like /nws) so auth middlewares cannot + /// gate raw websocket clients; RequestInfo is applied in-branch because + /// the WAF reads its output. + /// + public class RelayStartupFilter : IStartupFilter + { + public Action Configure(Action next) + { + return app => + { + app.Map("/wt/c", relayApp => + { + relayApp.UseRequestInfo(); + relayApp.UseWAF(); + relayApp.UseWebSockets(); + relayApp.Run(RelayServer.HandleWebSocketAsync); + }); + + next(app); + }; + } + } + + public class RelayMember + { + public string Uid { get; set; } + public string Alias { get; set; } + public bool Echo { get; set; } + public WebSocket Socket { get; set; } + public SemaphoreSlim SendLock { get; } = new(1, 1); + + /// LParty peer id (the u field of the member's messages). + public string Pid { get; set; } + public string Name { get; set; } + + public long RateSecond; + public int RateCount; + } + + public class RelayChannel + { + public string Name { get; set; } + public readonly object Lock = new(); + public readonly Dictionary Members = new(); + } + + /// + /// Wire-compatible emulation of the itty.ws channel relay used by lparty + /// clients: stateless broadcast groups with join/leave announcements, a + /// member list and a server date on every frame (the date is what lparty + /// uses for clock sync via message echo). + /// + public static class RelayServer + { + static long uidSeq = 0; + static readonly ConcurrentDictionary Channels = new(); + + public static RelayChannel TryGetChannel(string name) => + Channels.TryGetValue(name, out var channel) ? channel : null; + + /// Member uids excluding the server's virtual member. + public static List HumanUids(RelayChannel channel) + { + lock (channel.Lock) + return channel.Members.Keys + .Where(u => !string.Equals(u, ShadowHost.ServerUid, StringComparison.Ordinal)) + .ToList(); + } + + const int MaxMessageBytes = 16 * 1024; + const int MaxMessagesPerSecond = 50; + + public static async Task HandleWebSocketAsync(HttpContext context) + { + if (!ModInit.conf.enable) + { + context.Response.StatusCode = 404; + return; + } + + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } + + // Inside Map("/wt/c") the request path is already without the prefix: + // "/{channel}", so only the leading slash is stripped here. + string path = context.Request.Path.Value ?? "/"; + string channelName = path.Length > 1 ? Uri.UnescapeDataString(path[1..]) : string.Empty; + if (string.IsNullOrWhiteSpace(channelName) || channelName.Length > 128) + { + context.Response.StatusCode = 404; + return; + } + + bool echo = string.Equals(context.Request.Query["echo"].ToString(), "true", StringComparison.OrdinalIgnoreCase); + string alias = context.Request.Query["as"].ToString() ?? string.Empty; + if (alias.Length > 64) alias = alias[..64]; + + int maxChannels = Math.Max(1, ModInit.conf.relay_max_channels); + if (!Channels.ContainsKey(channelName) && Channels.Count >= maxChannels) + { + context.Response.StatusCode = 429; + return; + } + + var socket = await context.WebSockets.AcceptWebSocketAsync(); + + var member = new RelayMember + { + Uid = "u" + Interlocked.Increment(ref uidSeq), + Alias = alias, + Echo = echo, + Socket = socket + }; + + var channel = Channels.GetOrAdd(channelName, _ => new RelayChannel { Name = channelName }); + + // A server-hosted room keeps its virtual member across channel + // recreation: joiners must see total >= 2 or lparty clients + // declare the room dead on arrival. + if (ShadowHost.ShouldHostChannel(channelName)) + EnsureServerMember(channel); + + bool full = false; + List users; + int total; + lock (channel.Lock) + { + int maxClients = Math.Max(2, ModInit.conf.relay_max_clients_per_channel); + if (channel.Members.Count >= maxClients) + { + full = true; + users = new List(); + total = 0; + } + else + { + channel.Members[member.Uid] = member; + users = new List(channel.Members.Values); + total = users.Count; + } + } + + if (full) + { + _ = SendAsync(member, BuildErrorFrame(ServerNowMs(), "channel_full")); + try { await socket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "channel_full", CancellationToken.None); } catch { } + return; + } + + long now = ServerNowMs(); + _ = SendAsync(member, BuildSelfJoinFrame(member.Uid, now, total, users)); + + byte[] joinFrame = BuildForeignJoinFrame(member.Uid, member.Alias, now, total); + Broadcast(channel, member.Uid, joinFrame); + + ShadowHost.OnMemberJoined(channelName, channel, member); + + try + { + await ReceiveLoop(channelName, channel, member); + } + catch { } + finally + { + RemoveMember(channelName, channel, member); + } + } + + /// The server's virtual lparty member inside a channel: a + /// silent observer while a real host is alive, the acting host after + /// the host is gone. + public static void EnsureServerMember(RelayChannel channel) + { + bool added = false; + int total; + lock (channel.Lock) + { + if (!channel.Members.ContainsKey(ShadowHost.ServerUid)) + { + channel.Members[ShadowHost.ServerUid] = new RelayMember { Uid = ShadowHost.ServerUid, Alias = "Lampac" }; + added = true; + } + total = channel.Members.Count; + } + + if (added) + Broadcast(channel, null, BuildForeignJoinFrame(ShadowHost.ServerUid, "Lampac", ServerNowMs(), total)); + } + + static async Task ReceiveLoop(string channelName, RelayChannel channel, RelayMember member) + { + var socket = member.Socket; + var buffer = new byte[8192]; + + while (socket.State == WebSocketState.Open) + { + var result = await socket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) break; + + using var ms = new MemoryStream(); + ms.Write(buffer, 0, result.Count); + while (!result.EndOfMessage) + { + if (ms.Length > MaxMessageBytes) throw new IOException("relay message too large"); + result = await socket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) return; + ms.Write(buffer, 0, result.Count); + } + + if (ms.Length == 0 || ms.Length > MaxMessageBytes) continue; + + if (!CheckRate(member)) + { + _ = SendAsync(member, BuildErrorFrame(ServerNowMs(), "rate_limited")); + continue; + } + + JsonElement message; + try + { + using var doc = JsonDocument.Parse(ms.ToArray()); + if (doc.RootElement.ValueKind != JsonValueKind.Object) continue; + message = doc.RootElement.Clone(); + } + catch (JsonException) + { + continue; + } + + long now = ServerNowMs(); + + // The echo feeds clock sync: it happens for every message. + // Frames are broadcast verbatim; the shadow host only listens. + if (member.Echo) + { + byte[] echoFrame = BuildMessageFrame(member.Uid, member.Alias, ServerNowMs(), message); + _ = SendAsync(member, echoFrame); + } + + byte[] frame = BuildMessageFrame(member.Uid, member.Alias, now, message); + Broadcast(channel, member.Uid, frame); + + ShadowHost.OnMessage(channelName, channel, member, message, now); + } + } + + static bool CheckRate(RelayMember member) + { + long second = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (Interlocked.CompareExchange(ref member.RateSecond, second, second) != second) + { + member.RateSecond = second; + member.RateCount = 0; + } + member.RateCount++; + return member.RateCount <= MaxMessagesPerSecond; + } + + public static void Broadcast(RelayChannel channel, string excludeUid, byte[] frame) + { + RelayMember[] targets; + lock (channel.Lock) + { + if (channel.Members.Count == 0) return; + targets = new RelayMember[channel.Members.Count]; + channel.Members.Values.CopyTo(targets, 0); + } + + foreach (var target in targets) + { + if (string.Equals(target.Uid, excludeUid, StringComparison.Ordinal)) continue; + _ = SendAsync(target, frame); + } + } + + static void RemoveMember(string channelName, RelayChannel channel, RelayMember member) + { + int total; + bool tornDown = false; + lock (channel.Lock) + { + if (!channel.Members.Remove(member.Uid, out _)) return; + total = channel.Members.Count; + bool onlyServer = total == 1 && channel.Members.ContainsKey(ShadowHost.ServerUid); + + if (total == 0 || onlyServer) + { + tornDown = true; + if (Channels.TryRemove(channel.Name, out var stale) && !ReferenceEquals(stale, channel)) + { + // A newer instance took the name; put it back. + Channels.TryAdd(channel.Name, stale); + } + if (onlyServer) + channel.Members.Remove(ShadowHost.ServerUid, out _); + } + } + + byte[] leaveFrame = BuildLeaveFrame(member.Uid, member.Alias, ServerNowMs(), total); + Broadcast(channel, member.Uid, leaveFrame); + + ShadowHost.OnMemberLeft(channelName, channel, member, tornDown); + + try { member.Socket.Dispose(); } catch { } + } + + public static async Task SendAsync(RelayMember member, byte[] payload) + { + try + { + await member.SendLock.WaitAsync(); + try + { + if (member.Socket.State != WebSocketState.Open) return; + await member.Socket.SendAsync(new ArraySegment(payload), WebSocketMessageType.Text, true, CancellationToken.None); + } + finally + { + member.SendLock.Release(); + } + } + catch { } + } + + static long ServerNowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + static byte[] BuildSelfJoinFrame(string uid, long date, int total, List users) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("type", "join"); + w.WriteBoolean("self", true); + w.WriteString("uid", uid); + w.WriteNumber("date", date); + w.WriteNumber("total", total); + w.WriteStartArray("users"); + foreach (var u in users) + { + w.WriteStartObject(); + w.WriteString("uid", u.Uid); + w.WriteString("alias", u.Alias ?? string.Empty); + w.WriteEndObject(); + } + w.WriteEndArray(); + w.WriteEndObject(); + } + return ms.ToArray(); + } + + static byte[] BuildForeignJoinFrame(string uid, string alias, long date, int total) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("type", "join"); + w.WriteBoolean("self", false); + w.WriteString("uid", uid); + w.WriteString("alias", alias ?? string.Empty); + w.WriteNumber("date", date); + w.WriteNumber("total", total); + w.WriteEndObject(); + } + return ms.ToArray(); + } + + static byte[] BuildLeaveFrame(string uid, string alias, long date, int total) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("type", "leave"); + w.WriteString("uid", uid); + w.WriteString("alias", alias ?? string.Empty); + w.WriteNumber("date", date); + w.WriteNumber("total", total); + w.WriteEndObject(); + } + return ms.ToArray(); + } + + static byte[] BuildErrorFrame(long date, string message) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("type", "error"); + w.WriteNumber("date", date); + w.WriteString("message", message); + w.WriteEndObject(); + } + return ms.ToArray(); + } + + public static byte[] BuildMessageFrame(string uid, string alias, long date, JsonElement message) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("type", "message"); + w.WriteString("uid", uid); + w.WriteString("alias", alias ?? string.Empty); + w.WriteNumber("date", date); + w.WritePropertyName("message"); + message.WriteTo(w); + w.WriteEndObject(); + } + return ms.ToArray(); + } + + public static byte[] BuildMessageFrame(string uid, string alias, long date, object message) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("type", "message"); + w.WriteString("uid", uid); + w.WriteString("alias", alias ?? string.Empty); + w.WriteNumber("date", date); + w.WritePropertyName("message"); + JsonSerializer.Serialize(w, message, message.GetType()); + w.WriteEndObject(); + } + return ms.ToArray(); + } + } +} diff --git a/mods/WatchTogether/ShadowHost.cs b/mods/WatchTogether/ShadowHost.cs new file mode 100644 index 0000000..52234b4 --- /dev/null +++ b/mods/WatchTogether/ShadowHost.cs @@ -0,0 +1,826 @@ +using Shared; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace WatchTogether +{ + /// + /// Server-side state of a room the server eavesdrops on. The server never + /// creates rooms: a room exists because a client opened its channel and a + /// host announced a stream. Until the host vanishes, the server is a + /// silent member; when the host is gone, it seamlessly takes over. + /// + public class ShadowRoom + { + public readonly object Lock = new(); + + public string Channel; + + // host bookkeeping + public string HostUid; + public string HostPid; + public string CreatorPid; + public long HostSeen; + public bool Hosting; + public bool Stalled; + + // timeline + public string State = "paused"; + public double Position; + public long AtServerTime; + public double Speed = 1.0; + + // metadata + public string RoomName; + public string Url; + public string Title; + public string Poster; + public string Source; + public string Type; + public int Tmdb; + + /// Room code as announced by the host in the lobby (ad frames); + /// lets the server list and re-announce rooms it keeps alive. + public string Code; + + // server-side hold barrier (only while Hosting) + public HashSet HoldWaiting; + public double HoldPosition; + public Timer HoldTimer; + + public DateTime CreatedUtc = DateTime.UtcNow; + public DateTime LastActivityUtc = DateTime.UtcNow; + + /// Display name of the member that announced the room. + public string OwnerName; + } + + /// A room advertisement overheard in the lobby channel. + public class LobbyAd + { + public string SenderUid; + public long Seen; + public string Id; + public string Name; + public string Title; + public string Poster; + public string Owner; + public int Members; + public int Tmdb; + public string Type; + public bool Pwd; + } + + /// + /// The shadow host: the WatchTogether module acting as a state-keeping + /// lparty client. While a real host is alive it silently listens and keeps + /// a snapshot of the room (url, metadata, timeline). The moment the host's + /// connection drops, the server steps in as the host on the wire - the + /// members see one "host changed" toast and playback continues without a + /// gap. When the original creator returns, the role is handed back. + /// + public static class ShadowHost + { + public const string ServerUid = "lampac"; + + const string LobbyChannel = "lparty-lobby-v1"; + const string RoomPrefix = "lparty-r-"; + const int MetronomeMs = 2000; + const int GcEveryMs = 60000; + const double MaxAdvancableSeconds = 3600; + const int HoldMaxMs = 15000; + const long ReconnectGraceMs = 60000; + const int AdTtlMs = 120000; + + static Timer metronomeTimer; + static Timer gcTimer; + static int initialized; + + static readonly ConcurrentDictionary rooms = new(); + + /// Room advertisements overheard in the lobby channel: the + /// host announces its code publicly, so the server can keep listing + /// rooms (including ones it keeps alive) without knowing passwords. + static readonly ConcurrentDictionary lobbyAds = new(); + + /// pid -> last hello, per channel; used to skip the join hold + /// for members that are reconnecting rather than new. + static readonly ConcurrentDictionary> knownPids = new(); + + /// The wire channel name for a room code. With an empty + /// password this is computable server-side, which lets the server link + /// a lobby advertisement to the room it belongs to. + static string RoomChannelHash(string roomId, string password) + { + if (string.IsNullOrEmpty(roomId)) return null; + string input = roomId.ToUpperInvariant() + "|" + (password ?? string.Empty); + using var sha = SHA256.Create(); + byte[] hashBytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input)); + return RoomPrefix + Convert.ToHexString(hashBytes).ToLowerInvariant()[..24]; + } + + // ---------------------------------------------------------------- lifecycle + + public static void Start() + { + if (Interlocked.Exchange(ref initialized, 1) == 1) return; + metronomeTimer = new Timer(Metronome, null, MetronomeMs, MetronomeMs); + gcTimer = new Timer(Gc, null, GcEveryMs, GcEveryMs); + } + + public static void Stop() + { + if (Interlocked.Exchange(ref initialized, 0) == 0) return; + metronomeTimer?.Dispose(); + gcTimer?.Dispose(); + metronomeTimer = gcTimer = null; + rooms.Clear(); + knownPids.Clear(); + } + + // ---------------------------------------------------------------- relay hooks + + /// Whether the server's virtual member must exist in this + /// channel right now (it is the acting host). + public static bool ShouldHostChannel(string channelName) + { + if (channelName == LobbyChannel) return false; + return rooms.TryGetValue(channelName, out var room) && room.Hosting; + } + + public static void OnMemberJoined(string channelName, RelayChannel channel, RelayMember member) + { + if (channelName == LobbyChannel) return; + if (!rooms.TryGetValue(channelName, out var room)) return; + + room.LastActivityUtc = DateTime.UtcNow; + + RelayServer.EnsureServerMember(channel); + + // Server-hosted room: answer the joiner like a host would. + if (room.Hosting) + { + var pids = knownPids.GetOrAdd(channelName, _ => new ConcurrentDictionary()); + AnswerHelloAsHost(room, channel, member, pids, 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + } + } + + public static void OnMemberLeft(string channelName, RelayChannel channel, RelayMember member, bool tornDown) + { + if (channelName == LobbyChannel) + { + // A lobby agent disconnected: its ads die with it. + foreach (var key in lobbyAds.Keys.ToList()) + if (lobbyAds.TryGetValue(key, out var ad) && string.Equals(ad.SenderUid, member.Uid, StringComparison.Ordinal)) + lobbyAds.TryRemove(key, out var dead); + return; + } + + if (!rooms.TryGetValue(channelName, out var room)) return; + + if (!room.Hosting && !string.IsNullOrEmpty(room.HostUid) && string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal)) + BecomeHost(room, channel, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + } + + public static void OnMessage(string channelName, RelayChannel channel, RelayMember member, JsonElement msg, long now) + { + if (channelName == LobbyChannel) + { + HandleLobby(member, msg, now); + return; + } + + var room = rooms.GetOrAdd(channelName, _ => new ShadowRoom { Channel = channelName }); + room.LastActivityUtc = DateTime.UtcNow; + + string t = GetString(msg, "t"); + string pid = GetString(msg, "u"); + string name = GetString(msg, "n"); + + if (!string.IsNullOrEmpty(pid)) member.Pid = pid; + if (!string.IsNullOrEmpty(name)) member.Name = name; + + switch (t) + { + case "state": ObserveState(room, channel, member, msg, now); break; + case "sync": ObserveSync(room, member, msg, now); break; + case "act": ObserveAct(room, msg, now); break; + case "url": ObserveUrl(room, member, msg); break; + case "buf": ObserveBuf(room, member, msg); break; + case "host": HandleHostClaim(room, channel, member, msg, now); break; + case "hello": HandleHello(room, channel, member, msg, now); break; + case "ready": HandleReady(room, channel, member, now); break; + case "bye": HandleBye(room, channel, member, now); break; + } + } + + /// The lobby is server-answered: hosts advertise their code + /// in `ad` frames, the server caches them and re-announces rooms it + /// keeps alive - so the list survives a host handover. + static void HandleLobby(RelayMember member, JsonElement msg, long now) + { + string t = GetString(msg, "t"); + + if (t == "ad") + { + if (msg.TryGetProperty("r", out var r) && r.ValueKind == JsonValueKind.Object) + { + string id = GetString(r, "id"); + if (!string.IsNullOrWhiteSpace(id) && id.Length <= 16) + { + lobbyAds[id] = new LobbyAd + { + SenderUid = member.Uid, + Seen = now, + Id = id, + Name = GetString(r, "name") ?? id, + Title = GetString(r, "title") ?? string.Empty, + Poster = GetString(r, "poster") ?? string.Empty, + Owner = GetString(r, "owner") ?? string.Empty, + Members = GetInt(r, "members"), + Tmdb = GetInt(r, "tmdb"), + Type = GetString(r, "type") ?? "movie", + Pwd = GetInt(r, "pwd") != 0, + }; + } + } + return; + } + + if (t != "who") return; + + var listed = new HashSet(StringComparer.Ordinal); + + // Server-kept rooms first, with live member counts. + foreach (var room in rooms.Values) + { + string code, name, title, poster, owner, type; + int tmdb; + bool hasUrl; + lock (room.Lock) + { + code = room.Code; + hasUrl = !string.IsNullOrEmpty(room.Url); + name = room.RoomName; + title = room.Title; + poster = room.Poster; + owner = room.OwnerName; + type = room.Type; + tmdb = room.Tmdb; + } + + if (string.IsNullOrWhiteSpace(code) || listed.Contains(code) || !hasUrl) continue; + + var channel = RelayServer.TryGetChannel(room.Channel); + int members = channel == null ? 0 : RelayServer.HumanUids(channel).Count; + SendLobbyAd(member, now, code, name, title, poster, owner, members, true, tmdb, type); + listed.Add(code); + } + + // Cached ads from hosts. Provably alive (passwordless) rooms + // outlive the TTL; the rest go stale with their announcer. + foreach (var ad in lobbyAds.Values.OrderByDescending(a => a.Seen)) + { + if (listed.Contains(ad.Id)) continue; + + string hash = RoomChannelHash(ad.Id, string.Empty); + bool tracked = hash != null && rooms.ContainsKey(hash); + if (now - ad.Seen > AdTtlMs && !tracked) continue; + + int members = ad.Members; + if (tracked) + { + var channel = RelayServer.TryGetChannel(hash); + if (channel != null) members = RelayServer.HumanUids(channel).Count; + } + + SendLobbyAd(member, now, ad.Id, ad.Name, ad.Title, ad.Poster, ad.Owner, members, true, ad.Tmdb, ad.Type); + listed.Add(ad.Id); + } + } + + static void SendLobbyAd(RelayMember target, long now, string id, string name, string title, string poster, string owner, int members, bool pwd, int tmdb, string type) + { + var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new + { + t = "ad", + r = new { id, name, title, poster, owner, members, pwd = pwd ? 1 : 0, tmdb, type } + }); + _ = RelayServer.SendAsync(target, frame); + } + + // ---------------------------------------------------------------- observation + + static void ObserveState(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now) + { + // While hosting, only the creator may take the room back. + if (room.Hosting) + { + if (!string.Equals(member.Pid, room.CreatorPid, StringComparison.Ordinal)) + { + AssertHost(room, channel, now); + return; + } + Handback(room, channel, member, now); + } + + string url = GetString(msg, "url"); + if (string.IsNullOrWhiteSpace(url)) return; + + lock (room.Lock) + { + room.HostUid = member.Uid; + room.HostPid = GetString(msg, "own") ?? member.Pid ?? member.Uid; + room.CreatorPid = room.HostPid; + room.HostSeen = now; + room.Hosting = false; + room.Stalled = false; + + room.RoomName = GetString(msg, "rn") ?? room.RoomName; + room.Url = url.Trim(); + room.Title = GetString(msg, "ti") ?? room.Title; + room.Poster = GetString(msg, "po") ?? room.Poster; + room.Tmdb = GetInt(msg, "tm"); + room.Source = GetString(msg, "src") ?? room.Source; + room.Type = GetString(msg, "ty") ?? room.Type; + room.Code = GetString(msg, "cd") ?? room.Code; + room.OwnerName = member.Alias ?? room.OwnerName; + + room.State = GetString(msg, "s") == "playing" ? "playing" : "paused"; + double p = GetDouble(msg, "p"); + if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p; + room.Speed = GetSpeedOr(msg, room.Speed); + room.AtServerTime = now; + } + + RelayServer.EnsureServerMember(channel); + } + + static void ObserveSync(ShadowRoom room, RelayMember member, JsonElement msg, long now) + { + string s = GetString(msg, "s") == "playing" ? "playing" : "paused"; + double p = GetDouble(msg, "p"); + double sp = GetSpeedOr(msg, room.Speed); + + lock (room.Lock) + { + if (room.Hosting) return; + + bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) || + (!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal)); + + if (!fromHost && string.IsNullOrEmpty(room.HostUid)) + { + // No known host yet: adopt the sender. + room.HostUid = member.Uid; + room.HostPid = member.Pid; + room.CreatorPid = member.Pid; + fromHost = true; + } + + if (!fromHost) return; + + room.State = s; + if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p; + room.Speed = sp; + room.AtServerTime = now; + room.HostSeen = now; + } + } + + static void ObserveAct(ShadowRoom room, JsonElement msg, long now) + { + // Acts from anyone move the room; the host's next sync re-asserts. + if (room.Hosting) return; + + string s = GetString(msg, "s") == "playing" ? "playing" : "paused"; + double p = GetDouble(msg, "p"); + + lock (room.Lock) + { + room.State = s; + if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p; + room.Speed = GetSpeedOr(msg, room.Speed); + room.AtServerTime = now; + } + } + + static void ObserveUrl(ShadowRoom room, RelayMember member, JsonElement msg) + { + lock (room.Lock) + { + bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) || + (!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal)); + if (!fromHost || room.Hosting) return; + + string url = GetString(msg, "url"); + if (string.IsNullOrWhiteSpace(url)) return; + + room.Url = url.Trim(); + string ti = GetString(msg, "ti"); + if (!string.IsNullOrEmpty(ti)) room.Title = ti.Trim(); + room.State = "paused"; + room.Position = 0; + room.AtServerTime = 0; + room.Stalled = false; + } + } + + static void ObserveBuf(ShadowRoom room, RelayMember member, JsonElement msg) + { + lock (room.Lock) + { + bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) || + (!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal)); + if (!fromHost) return; + + room.Stalled = GetBool(msg, "v"); + } + } + + static void HandleHostClaim(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now) + { + if (room.Hosting) + { + // Claims are dropped; the server re-asserts itself. + AssertHost(room, channel, now); + return; + } + + string claimer = GetString(msg, "u"); + if (string.IsNullOrEmpty(claimer)) return; + + lock (room.Lock) + { + room.HostUid = member.Uid; + room.HostPid = claimer; + room.CreatorPid = claimer; + room.HostSeen = now; + room.Hosting = false; + } + } + + static void HandleHello(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now) + { + string pid = GetString(msg, "u") ?? member.Pid; + string name = GetString(msg, "n"); + string cd = GetString(msg, "cd"); + + var pids = knownPids.GetOrAdd(channel.Name, _ => new ConcurrentDictionary()); + long seenBefore = 0; + if (!string.IsNullOrEmpty(pid)) + { + pids.TryGetValue(pid, out seenBefore); + pids[pid] = now; + } + + // The first participant stating the code links the room to its listing. + if (!string.IsNullOrWhiteSpace(cd)) + { + lock (room.Lock) + { + if (string.IsNullOrEmpty(room.Code)) room.Code = cd.Trim().ToUpperInvariant(); + } + } + + if (!room.Hosting) return; + + if (!string.IsNullOrEmpty(pid) && string.Equals(pid, room.CreatorPid, StringComparison.Ordinal)) + { + Handback(room, channel, member, now); + return; + } + + AnswerHelloAsHost(room, channel, member, pids, seenBefore, now); + } + + /// Server-hosting room, newcomer arrived: introduce the room + /// and, like a real host, freeze it while they buffer. + static void AnswerHelloAsHost(ShadowRoom room, RelayChannel channel, RelayMember member, ConcurrentDictionary pids, long seenBefore, long now) + { + if (!room.Hosting) return; + + RelayServer.EnsureServerMember(channel); + SendStateFrame(room, member); + + bool reconnected = now - seenBefore < ReconnectGraceMs; + + bool playing; + lock (room.Lock) playing = room.State == "playing" && !string.IsNullOrEmpty(room.Url); + + if (!playing) return; + if (reconnected) return; + + StartHold(room, channel, now); + } + + static void HandleReady(ShadowRoom room, RelayChannel channel, RelayMember member, long now) + { + lock (room.Lock) + { + if (!room.Hosting || room.HoldWaiting == null) return; + room.HoldWaiting.Remove(member.Uid); + if (room.HoldWaiting.Count > 0) return; + } + ReleaseHold(room, channel, now); + } + + static void HandleBye(ShadowRoom room, RelayChannel channel, RelayMember member, long now) + { + bool wasHost; + lock (room.Lock) + wasHost = !room.Hosting && !string.IsNullOrEmpty(room.HostUid) && string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal); + + if (wasHost) BecomeHost(room, channel, now); + } + + // ---------------------------------------------------------------- host transitions + + static void BecomeHost(ShadowRoom room, RelayChannel channel, long now) + { + if (room.Hosting) return; + + lock (room.Lock) + { + room.Hosting = true; + room.HostUid = ServerUid; + room.HostSeen = now; + room.Stalled = false; + + if (room.State == "playing" && room.AtServerTime > 0) + { + // Continue exactly where the dead host was last heard. + double elapsed = (now - room.AtServerTime) / 1000.0; + if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) + room.Position += elapsed * room.Speed; + room.AtServerTime = now; + } + } + + RelayServer.EnsureServerMember(channel); + AssertHost(room, channel, now); + + double pos; + string state; + double speed; + lock (room.Lock) { state = room.State; pos = room.Position; speed = room.Speed; } + var sync = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "sync", u = ServerUid, s = state, p = pos, sp = speed }); + RelayServer.Broadcast(channel, null, sync); + } + + static void Handback(ShadowRoom room, RelayChannel channel, RelayMember member, long now) + { + lock (room.Lock) + { + room.Hosting = false; + room.HostUid = member.Uid; + room.HostPid = member.Pid; + room.HostSeen = now; + room.Stalled = false; + } + + var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "host", u = member.Pid, n = member.Name ?? member.Alias ?? string.Empty }); + RelayServer.Broadcast(channel, null, frame); + } + + static void AssertHost(ShadowRoom room, RelayChannel channel, long now) + { + var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "host", u = ServerUid, n = "Lampac" }); + RelayServer.Broadcast(channel, null, frame); + } + + // ---------------------------------------------------------------- hold barrier (server as host) + + static void StartHold(ShadowRoom room, RelayChannel channel, long now) + { + lock (room.Lock) + { + if (room.HoldWaiting != null) return; // already holding + + AdvanceClock(room, now); + room.HoldPosition = room.Position; + + room.HoldWaiting = new HashSet(RelayServer.HumanUids(channel), StringComparer.Ordinal); + if (room.HoldWaiting.Count == 0) return; + } + + var holdFrame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "hold", u = ServerUid, p = room.HoldPosition, sp = room.Speed }); + RelayServer.Broadcast(channel, null, holdFrame); + + lock (room.Lock) + { + room.HoldTimer?.Dispose(); + room.HoldTimer = new Timer(_ => + { + var ch = RelayServer.TryGetChannel(room.Channel); + if (ch != null) ReleaseHold(room, ch, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + }, null, HoldMaxMs, Timeout.Infinite); + } + } + + /// Freeze the clock base at the current position. + static void AdvanceClock(ShadowRoom room, long now) + { + if (room.State == "playing" && room.AtServerTime > 0) + { + double elapsed = (now - room.AtServerTime) / 1000.0; + if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) room.Position += elapsed * room.Speed; + } + room.AtServerTime = now; + } + + static void ReleaseHold(ShadowRoom room, RelayChannel channel, long now) + { + double position; + lock (room.Lock) + { + room.HoldWaiting = null; + room.HoldTimer?.Dispose(); + room.HoldTimer = null; + room.State = "playing"; + room.Position = room.HoldPosition; + room.AtServerTime = now; + position = room.HoldPosition; + } + + var goFrame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "go", u = ServerUid, p = position, sp = room.Speed }); + RelayServer.Broadcast(channel, null, goFrame); + } + + // ---------------------------------------------------------------- metronome + + /// While the server hosts, nobody's player drives the clock: + /// the metronome advances the room by wall time and beats every couple + /// of seconds, exactly like the host heartbeats it replaces. + static void Metronome(object _) + { + try + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + foreach (var room in rooms.Values) + { + if (!room.Hosting) continue; + var channel = RelayServer.TryGetChannel(room.Channel); + if (channel == null) continue; + + bool playing; + lock (room.Lock) + { + playing = room.State == "playing" && !room.Stalled; + if (playing && room.AtServerTime > 0) + { + double elapsed = (now - room.AtServerTime) / 1000.0; + if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) + room.Position += elapsed * room.Speed; + room.AtServerTime = now; + } + } + + if (!playing) continue; + + double pos, speed; + string state; + lock (room.Lock) { pos = room.Position; speed = room.Speed; state = room.State; } + + var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "sync", u = ServerUid, s = state, p = pos, sp = speed }); + RelayServer.Broadcast(channel, null, frame); + } + } + catch { } + } + + // ---------------------------------------------------------------- gc + + static void Gc(object _) + { + try + { + var emptyLimit = DateTime.UtcNow.AddMinutes(-Math.Max(5, ModInit.conf.gc_empty_timeout_minutes)); + var lifeLimit = DateTime.UtcNow.AddHours(-Math.Max(1, ModInit.conf.gc_max_lifetime_hours)); + + foreach (var room in rooms.Values.ToArray()) + { + bool hasHumans = false; + var channel = RelayServer.TryGetChannel(room.Channel); + if (channel != null) + { + lock (channel.Lock) + hasHumans = channel.Members.Keys.Any(u => !string.Equals(u, ServerUid, StringComparison.Ordinal)); + } + + bool expired = room.CreatedUtc < lifeLimit || + (!hasHumans && room.LastActivityUtc < emptyLimit); + + if (expired) + { + rooms.TryRemove(room.Channel, out var deadRoom); + knownPids.TryRemove(room.Channel, out var deadPids); + } + } + + long cutoff = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - ReconnectGraceMs; + foreach (var pids in knownPids.Values) + { + foreach (var key in pids.Keys.ToList()) + { + if (!pids.TryGetValue(key, out var seen) || seen < cutoff) + pids.TryRemove(key, out var deadPid); + } + } + + long adCutoff = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - AdTtlMs; + foreach (var key in lobbyAds.Keys.ToList()) + { + if (!lobbyAds.TryGetValue(key, out var ad) || ad.Seen < adCutoff) + lobbyAds.TryRemove(key, out var deadAd); + } + } + catch { } + } + + // ---------------------------------------------------------------- helpers + + static void SendStateFrame(ShadowRoom room, RelayMember target) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + double pos; + string state; + double speed; + lock (room.Lock) + { + pos = room.Position; + speed = room.Speed; + state = room.State; + if (state == "playing" && room.AtServerTime > 0) + { + double elapsed = (now - room.AtServerTime) / 1000.0; + if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) pos += elapsed * room.Speed; + } + } + + var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new + { + t = "state", + rn = room.RoomName ?? string.Empty, + own = ServerUid, + url = room.Url ?? string.Empty, + ti = room.Title ?? string.Empty, + po = room.Poster ?? string.Empty, + tm = room.Tmdb, + src = room.Source ?? string.Empty, + ty = room.Type ?? "movie", + s = state, + p = pos, + sp = speed, + }); + + _ = RelayServer.SendAsync(target, frame); + } + + static string GetString(JsonElement obj, string name) + { + if (obj.ValueKind != JsonValueKind.Object) return null; + if (!obj.TryGetProperty(name, out var el)) return null; + if (el.ValueKind == JsonValueKind.String) return el.GetString(); + if (el.ValueKind == JsonValueKind.Null) return null; + return el.ToString(); + } + + static double GetDouble(JsonElement obj, string name) + { + if (obj.ValueKind != JsonValueKind.Object) return 0; + if (obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetDouble(out var val)) + return val; + return 0; + } + + static double GetSpeedOr(JsonElement obj, double fallback) + { + double v = GetDouble(obj, "sp"); + return v >= 0.25 && v <= 4.0 ? v : fallback; + } + + static int GetInt(JsonElement obj, string name) + { + if (obj.ValueKind != JsonValueKind.Object) return 0; + if (obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var val)) + return val; + return 0; + } + + static bool GetBool(JsonElement obj, string name) + { + if (obj.ValueKind != JsonValueKind.Object) return false; + return obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.True; + } + } +} diff --git a/mods/WatchTogether/WatchTogether.csproj b/mods/WatchTogether/WatchTogether.csproj new file mode 100644 index 0000000..74cb30d --- /dev/null +++ b/mods/WatchTogether/WatchTogether.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + true + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/mods/WatchTogether/WatchTogetherController.cs b/mods/WatchTogether/WatchTogetherController.cs new file mode 100644 index 0000000..698cda3 --- /dev/null +++ b/mods/WatchTogether/WatchTogetherController.cs @@ -0,0 +1,92 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Shared; +using System; +using System.IO; + +namespace WatchTogether +{ + /// + /// Static file service: the Lampa plugin (adapted lparty.js) and the web + /// guest page (adapted lparty-web), with lampac hosts injected as + /// placeholders. The relay itself lives at /c/ (RelayStartupFilter). + /// + [Route("watchtogether")] + public class WatchTogetherController : BaseController + { + private bool CheckAuth() + { + if (ModInit.conf.allow_anonymous) return true; + if (!CoreInit.conf.accsdb.enable) return true; + return requestInfo.user != null; + } + + [AllowAnonymous] + [HttpGet] + [Route("/watchtogether.js")] + public ActionResult GetPlugin() + { + if (!CheckAuth()) return StatusCode(401); + return ServeFile("plugin.js", "application/javascript; charset=utf-8", js => + js.Replace("{relay}", RelayAddress()) + .Replace("{invite}", InviteAddress())); + } + + [AllowAnonymous] + [HttpGet] + [Route("/watch_together")] + [Route("/watch_together/{file}")] + public ActionResult GetWebPlayer(string file) + { + if (!CheckAuth()) return StatusCode(401); + + switch (file ?? string.Empty) + { + case "": + case "index.html": + return ServeFile("web/index.html", "text/html; charset=utf-8", html => + html.Replace("{relay}", RelayAddress())); + case "lparty-web.js": + return ServeFile("web/lparty-web.js", "application/javascript; charset=utf-8", js => + js.Replace("{relay}", RelayAddress())); + case "i18n.js": + return ServeFile("web/i18n.js", "application/javascript; charset=utf-8", null); + case "names.js": + return ServeFile("web/names.js", "application/javascript; charset=utf-8", null); + default: + return NotFound(); + } + } + + string RelayAddress() + { + return (host.StartsWith("https", StringComparison.Ordinal) ? "wss://" : "ws://") + + host.Replace("https://", "").Replace("http://", "").TrimEnd('/') + "/wt/c/"; + } + + string InviteAddress() + { + return host.TrimEnd('/') + "/watch_together/"; + } + + ActionResult ServeFile(string name, string contentType, Func transform) + { + string path = Path.Combine(ModInit.modpath, name); + if (!System.IO.File.Exists(path)) + return Content("

" + name + " not found

", "text/html; charset=utf-8"); + + long mtime = System.IO.File.GetLastWriteTimeUtc(path).Ticks; + string memKey = "watchtogether:file:" + name + ":" + mtime; + + if (!memoryCache.TryGetValue(memKey, out string raw)) + { + raw = System.IO.File.ReadAllText(path); + memoryCache.Set(memKey, raw, TimeSpan.FromMinutes(10)); + } + + string result = transform != null ? transform(raw) : raw; + return Content(result, contentType); + } + } +} diff --git a/mods/WatchTogether/manifest.json b/mods/WatchTogether/manifest.json new file mode 100644 index 0000000..c894096 --- /dev/null +++ b/mods/WatchTogether/manifest.json @@ -0,0 +1,10 @@ +{ + "enable": true, + "dynamic": true, + "tree": [ + "ModInit.cs", + "RelayServer.cs", + "ShadowHost.cs", + "WatchTogetherController.cs" + ] +} \ No newline at end of file diff --git a/mods/WatchTogether/plugin.js b/mods/WatchTogether/plugin.js new file mode 100644 index 0000000..cd638bd --- /dev/null +++ b/mods/WatchTogether/plugin.js @@ -0,0 +1,3651 @@ +(function () { + "use strict"; + + if (window.WatchTogether_plugin_started) { + console.log("[WatchTogether]", "Already running."); + return; + } + window.WatchTogether_plugin_started = true; + + var META = { + name: "WatchTogether", + version: "2.0.0", + author: "arabianq, nrsua, levende", + }; + + var DEFAULT_RELAY = "{relay}"; + var DEFAULT_INVITE = "{invite}"; + // Wire-protocol constants shared with all lparty clients (JustPlus etc.) — do not rename. + var LOBBY_CHANNEL = "lparty-lobby-v1"; + var ROOM_PREFIX = "lparty-r-"; + + var _rawLang = (Lampa.Storage.get("language") || "en").toLowerCase(); + var i18n = { + uk: { + menu_title: "WatchTogether", + settings_title: "WatchTogether", + param_show_head: "Відображати WatchTogether на головній", + param_show_head_descr: + "Кнопка WatchTogether у верхній панелі головного екрана.", + param_name: "Ім'я користувача", + param_name_descr: + "Як вас бачитимуть інші в кімнатах. Якщо очистити - буде згенеровано випадкове ім'я.", + param_use_pwd: "Використовувати пароль", + param_use_pwd_descr: + "Запитувати пароль при створенні кімнати та підставляти у власні кімнати.", + param_pwd: "Пароль за замовчуванням", + param_pwd_descr: "Буде використано при створенні кімнат із паролем.", + param_publish: "Показувати кімнату в списку", + param_publish_descr: + "Вимкніть, щоб до вашої кімнати можна було зайти лише за кодом.", + param_relay: "Адреса реле", + param_relay_descr: "WebSocket-реле. За замовчуванням " + DEFAULT_RELAY, + param_invite: "Адреса запрошення", + param_invite_descr: + "Базове посилання для QR-коду. За замовчуванням " + DEFAULT_INVITE, + head_title: "WatchTogether - список кімнат", + create_btn: "Створити кімнату за посиланням", + join_code_btn: "Увійти за кодом кімнати", + full_card_btn: "WatchTogether - Дивитися з друзями", + settings_open_rooms: "Відкрити список кімнат", + settings_open_rooms_descr: + "Показує список доступних кімнат і дозволяє створити свою", + empty_list: "Відкритих кімнат немає", + searching: "Пошук кімнат...", + input_url: "Посилання на потік (m3u8 / mp4)", + input_room_name: "Назва кімнати", + input_password: "Пароль кімнати", + input_join_password: "Введіть пароль кімнати", + input_room_code: "Код кімнати", + connecting: "Підключення...", + create_fail: "Не вдалося створити кімнату", + create_ok: function (n) { + return 'Кімнату "' + n + '" створено'; + }, + room_code: function (c) { + return "Код кімнати: " + c; + }, + join_ok: function (n) { + return 'Ви увійшли до кімнати "' + n + '"'; + }, + no_room: "Кімнату не знайдено або невірний пароль", + no_stream: "У цій кімнаті немає потоку", + kicked: "Ви увійшли до цієї кімнати з іншого пристрою", + host_left: "Хост покинув кімнату - перегляд завершено", + net_err: "Помилка мережі", + need_url: "Не задано посилання на потік", + publish_needs_pwd: "Немає у публічному списку: доступ лише за кодом", + create_from_player: "Поділитися останнім потоком", + pending_share: + "Запустіть відтворення - кімнату буде створено автоматично", + label_owner: "Хост", + label_members: "Глядачів", + badge_room: "Кімната", + badge_viewers: "Глядачів", + notice_joined: function (n) { + return n + " приєднався"; + }, + notice_left: function (n) { + return n + " покинув кімнату"; + }, + notice_paused: function (n) { + return n + " поставив паузу"; + }, + notice_resumed: function (n) { + return n + " продовжив відтворення"; + }, + notice_seeked: function (n) { + return n + " перемотав"; + }, + notice_host_changed: function (n) { + return "Новий хост: " + n; + }, + player_create_descr: "Створити кімнату на цей потік", + player_qr_descr: "Показати QR-код для запрошення друзів", + qr_title: "Приєднатися до кімнати", + qr_hint: "Скануйте камерою телефона", + qr_copy: "Копіювати посилання", + qr_share: "Поділитися", + qr_copied: "Посилання скопійовано", + qr_copy_fail: "Не вдалося скопіювати посилання", + already_in_room: function (n) { + return 'Ви вже в кімнаті "' + n + '"'; + }, + leave_btn: "Покинути кімнату", + left_ok: "Ви покинули кімнату", + notice_hold: "Пауза - чекаємо, поки всі завантажать буфер", + notice_go: "Усі готові - продовжуємо", + }, + en: { + menu_title: "WatchTogether", + settings_title: "WatchTogether", + param_show_head: "Show WatchTogether on the main screen", + param_show_head_descr: + "WatchTogether button in the top bar of the main screen.", + param_name: "Display name", + param_name_descr: + "How others see you in rooms. Clear it to generate a random name.", + param_use_pwd: "Use password", + param_use_pwd_descr: + "Ask for password when creating a room and prefill your default.", + param_pwd: "Default password", + param_pwd_descr: "Used when creating password-protected rooms.", + param_publish: "List room publicly", + param_publish_descr: "Turn off to make your room reachable by code only.", + param_relay: "Relay address", + param_relay_descr: "WebSocket relay. Default is " + DEFAULT_RELAY, + param_invite: "Invite address", + param_invite_descr: + "Base link used in the QR code. Default is " + DEFAULT_INVITE, + head_title: "WatchTogether - available rooms", + create_btn: "Create room from URL", + join_code_btn: "Join by room code", + full_card_btn: "WatchTogether - Watch with friends", + settings_open_rooms: "Open room browser", + settings_open_rooms_descr: + "Shows the list of available rooms and lets you create your own", + empty_list: "No open rooms", + searching: "Looking for rooms...", + input_url: "Stream URL (m3u8 / mp4)", + input_room_name: "Room name", + input_password: "Room password", + input_join_password: "Enter room password", + input_room_code: "Room code", + connecting: "Connecting...", + create_fail: "Failed to create room", + create_ok: function (n) { + return 'Room "' + n + '" created'; + }, + room_code: function (c) { + return "Room code: " + c; + }, + join_ok: function (n) { + return 'Joined room "' + n + '"'; + }, + no_room: "Room not found or wrong password", + no_stream: "This room has no stream", + kicked: "You joined this room from another device", + host_left: "Host left the room - session ended", + net_err: "Network error", + need_url: "Stream URL is empty", + publish_needs_pwd: "Not in the public list: access by code only", + create_from_player: "Share last stream", + pending_share: "Start playback - the room will be created automatically", + label_owner: "Host", + label_members: "Viewers", + badge_room: "Room", + badge_viewers: "Viewers", + notice_joined: function (n) { + return n + " joined"; + }, + notice_left: function (n) { + return n + " left the room"; + }, + notice_paused: function (n) { + return n + " paused"; + }, + notice_resumed: function (n) { + return n + " resumed playback"; + }, + notice_seeked: function (n) { + return n + " seeked"; + }, + notice_host_changed: function (n) { + return "New host: " + n; + }, + player_create_descr: "Create a room for this stream", + player_qr_descr: "Show a QR code to invite friends", + qr_title: "Join the room", + qr_hint: "Scan with your phone camera", + qr_copy: "Copy link", + qr_share: "Share", + qr_copied: "Link copied", + qr_copy_fail: "Could not copy the link", + already_in_room: function (n) { + return 'You are already in room "' + n + '"'; + }, + leave_btn: "Leave room", + left_ok: "You left the room", + notice_hold: "Paused - waiting for everyone to buffer", + notice_go: "Everyone is ready - resuming", + }, + ru: { + menu_title: "WatchTogether", + settings_title: "WatchTogether", + param_show_head: "Показывать WatchTogether на главной", + param_show_head_descr: + "Кнопка WatchTogether в верхней панели главного экрана.", + param_name: "Имя пользователя", + param_name_descr: + "Как вас будут видеть в комнатах. Если очистить - будет сгенерировано случайное имя.", + param_use_pwd: "Использовать пароль", + param_use_pwd_descr: + "Запрашивать пароль при создании комнаты и подставлять в свои комнаты.", + param_pwd: "Пароль по умолчанию", + param_pwd_descr: "Будет использоваться при создании комнат с паролем.", + param_publish: "Показывать комнату в списке", + param_publish_descr: + "Выключите, чтобы в вашу комнату можно было войти только по коду.", + param_relay: "Адрес реле", + param_relay_descr: "WebSocket-реле. По умолчанию " + DEFAULT_RELAY, + param_invite: "Адрес приглашения", + param_invite_descr: + "Базовая ссылка для QR-кода. По умолчанию " + DEFAULT_INVITE, + head_title: "WatchTogether - список комнат", + create_btn: "Создать комнату по ссылке", + join_code_btn: "Войти по коду комнаты", + full_card_btn: "WatchTogether - Смотреть с друзьями", + settings_open_rooms: "Открыть список комнат", + settings_open_rooms_descr: + "Показывает список доступных комнат и позволяет создать свою", + empty_list: "Открытых комнат нет", + searching: "Поиск комнат...", + input_url: "Ссылка на поток (m3u8 / mp4)", + input_room_name: "Название комнаты", + input_password: "Пароль комнаты", + input_join_password: "Введите пароль комнаты", + input_room_code: "Код комнаты", + connecting: "Подключение...", + create_fail: "Не удалось создать комнату", + create_ok: function (n) { + return 'Комната "' + n + '" создана'; + }, + room_code: function (c) { + return "Код комнаты: " + c; + }, + join_ok: function (n) { + return 'Вошли в комнату "' + n + '"'; + }, + no_room: "Комната не найдена или неверный пароль", + no_stream: "В этой комнате нет потока", + kicked: "Вы вошли в комнату с другого устройства", + host_left: "Хост покинул комнату - просмотр завершён", + net_err: "Ошибка сети", + need_url: "Не задана ссылка на поток", + publish_needs_pwd: "Нет в публичном списке: доступ только по коду", + create_from_player: "Поделиться последним потоком", + pending_share: + "Запустите воспроизведение - комната будет создана автоматически", + label_owner: "Хост", + label_members: "Зрителей", + badge_room: "Комната", + badge_viewers: "Зрителей", + notice_joined: function (n) { + return n + " присоединился"; + }, + notice_left: function (n) { + return n + " покинул комнату"; + }, + notice_paused: function (n) { + return n + " поставил паузу"; + }, + notice_resumed: function (n) { + return n + " продолжил воспроизведение"; + }, + notice_seeked: function (n) { + return n + " перемотал"; + }, + notice_host_changed: function (n) { + return "Новый хост: " + n; + }, + player_create_descr: "Создать комнату с этим потоком", + player_qr_descr: "Показать QR-код для приглашения друзей", + qr_title: "Присоединиться к комнате", + qr_hint: "Сканируйте камерой телефона", + qr_copy: "Копировать ссылку", + qr_share: "Поделиться", + qr_copied: "Ссылка скопирована", + qr_copy_fail: "Не удалось скопировать ссылку", + already_in_room: function (n) { + return 'Вы уже в комнате "' + n + '"'; + }, + leave_btn: "Покинуть комнату", + left_ok: "Вы покинули комнату", + notice_hold: "Пауза - ждём, пока все загрузят буфер", + notice_go: "Все готовы - продолжаем", + }, + }; + var T = i18n[_rawLang] || i18n["en"]; + + var LOBBY_COLLECT_MS = 1500; + var JOIN_TIMEOUT_MS = 6000; + var JOIN_EMPTY_TIMEOUT_MS = 2500; + var PING_INTERVAL_MS = 8000; + var ECHO_TIMEOUT_MS = 30000; + var RECONNECT_MS = 4000; + + var ROOM_ORPHAN_MS = 20000; + var EPISODE_SWITCH_MS = 20000; + + var SYNC_HEARTBEAT_MS = 2000; + var SYNC_TOLERANCE_S = 0.3; + var SYNC_CORRECT_ON_S = 0.7; + var SYNC_HARD_SEEK_S = 1.5; + var SYNC_HARD_SEEK_APPLE_S = 3.0; + var SEEK_GUARD_MS = 4000; + var SEEK_BROADCAST_MIN_MS = 2000; + var SEEK_MIN_JUMP_S = 1.0; + var SYNC_RATE_GAIN = 0.1; + var SYNC_MAX_RATE_OFFSET = 0.1; + var SYNC_RATE_RESET_MS = 4000; + var REWIND_GRACE_MS = 2500; + + // Playback speed of the room. Announced in the sp field of + // state/sync/act/hold/go frames; the rate correction works around this base. + var roomSpeed = 1.0; + + function applyRoomSpeed(m) { + if (!m || typeof m.sp !== "number" || !(m.sp >= 0.25 && m.sp <= 4.0)) + return; + if (Math.abs(m.sp - roomSpeed) < 0.005) return; + roomSpeed = m.sp; + var vid = getVideo(); + if ( + vid && + canAdjustRate() && + !vid._lp_correcting && + !isSystemSyncing && + Math.abs((vid.playbackRate || 1) - roomSpeed) > 0.01 + ) { + vid.playbackRate = roomSpeed; + } + } + + var HOLD_MAX_MS = 15000; + var HOLD_BUFFER_S = 6; + var HOLD_MIN_BUFFER_S = 2; + var HOLD_STALL_MS = 3000; + var HOLD_NUDGE_MS = 4000; + var NO_RANGES = -2; + var OUT_OF_RANGE = -1; + var PENDING_SHARE_MS = 300000; + var SHARE_START_DELAY_MS = 600; + var HARD_SEEK_COOLDOWN_MS = 3000; + var PENDING_ACT_MAX_MS = 15000; + var RECONNECT_HELLO_MS = 60000; + var LOG_LIMIT = 400; + + var logRing = []; + + var playerLaunch = Lampa.Storage.get("watchtogether_player", "inner"); + + function lplog() { + var text = Array.prototype.slice.call(arguments).join(" "); + var stamp = new Date().toISOString().substr(11, 12); + + logRing.push(stamp + " " + text); + if (logRing.length > LOG_LIMIT) logRing.shift(); + + return text; + } + + var pid = Lampa.Storage.get("watchtogether_pid", ""); + if (!pid) { + pid = Lampa.Utils.uid(12).toLowerCase(); + Lampa.Storage.set("watchtogether_pid", pid); + } + + var NAME_ADJECTIVES = [ + "Amber", + "Arctic", + "Atomic", + "Brave", + "Bright", + "Calm", + "Chilly", + "Clever", + "Cosmic", + "Crimson", + "Curious", + "Dizzy", + "Dusty", + "Electric", + "Emerald", + "Fancy", + "Fearless", + "Frosty", + "Fuzzy", + "Gentle", + "Golden", + "Happy", + "Hidden", + "Humble", + "Indigo", + "Iron", + "Jolly", + "Lively", + "Lucky", + "Lunar", + "Magic", + "Marble", + "Mighty", + "Mystic", + "Neon", + "Nimble", + "Noble", + "Northern", + "Plasma", + "Polar", + "Quiet", + "Rapid", + "Retro", + "Royal", + "Rusty", + "Scarlet", + "Shiny", + "Silent", + "Silver", + "Sleepy", + "Snowy", + "Solar", + "Sunny", + "Swift", + "Thunder", + "Tiny", + "Turbo", + "Velvet", + "Vivid", + "Wild", + ]; + + var NAME_NOUNS = [ + "Badger", + "Bandit", + "Bear", + "Beaver", + "Bison", + "Camel", + "Captain", + "Cheetah", + "Comet", + "Condor", + "Cookie", + "Coyote", + "Crane", + "Dingo", + "Dolphin", + "Donkey", + "Dragon", + "Eagle", + "Falcon", + "Ferret", + "Fox", + "Gecko", + "Ghost", + "Gibbon", + "Griffin", + "Hamster", + "Hawk", + "Hedgehog", + "Heron", + "Husky", + "Ibex", + "Jaguar", + "Kitten", + "Koala", + "Kraken", + "Lemur", + "Leopard", + "Lion", + "Llama", + "Lynx", + "Mammoth", + "Marmot", + "Meteor", + "Monkey", + "Moose", + "Narwhal", + "Ninja", + "Nomad", + "Ocelot", + "Octopus", + "Osprey", + "Otter", + "Owl", + "Panda", + "Panther", + "Parrot", + "Pelican", + "Penguin", + "Pilot", + "Pirate", + "Puffin", + "Puma", + "Rabbit", + "Raccoon", + "Ranger", + "Raven", + "Rhino", + "Robot", + "Rocket", + "Salmon", + "Seal", + "Shark", + "Sparrow", + "Tiger", + "Toucan", + "Turtle", + "Viking", + "Walrus", + "Wizard", + "Wolf", + "Wombat", + "Yeti", + "Zebra", + ]; + + function pickOne(list) { + return list[Math.floor(Math.random() * list.length)]; + } + + function randomDisplayName() { + return ( + pickOne(NAME_ADJECTIVES) + + pickOne(NAME_NOUNS) + + (10 + Math.floor(Math.random() * 90)) + ); + } + + function getDisplayName() { + var custom = (Lampa.Storage.get("watchtogether_display_name", "") || "") + .toString() + .trim(); + if (!custom) { + custom = randomDisplayName(); + Lampa.Storage.set("watchtogether_display_name", custom); + lplog("display name generated: " + custom); + } + return custom; + } + + function isUsePassword() { + return Lampa.Storage.field("watchtogether_use_password") === true; + } + + function getDefaultPassword() { + return ( + Lampa.Storage.get("watchtogether_default_password", "") || "" + ).toString(); + } + + function isPublish() { + return Lampa.Storage.field("watchtogether_publish") !== false; + } + + function isShowHead() { + return Lampa.Storage.get("watchtogether_show_head", "true") === true; + } + + function getRelay() { + var v = (Lampa.Storage.get("watchtogether_relay", "") || "") + .toString() + .trim(); + if (!v) v = DEFAULT_RELAY; + if (v.charAt(v.length - 1) !== "/") v += "/"; + return v; + } + + function getInvite() { + var v = (Lampa.Storage.get("watchtogether_invite", "") || "") + .toString() + .trim(); + return v || DEFAULT_INVITE; + } + + function safe(s) { + return (s + "").replace(/[<>&"']/g, function (c) { + return "&#" + c.charCodeAt(0) + ";"; + }); + } + + var uiPrevController = "content"; + + function controllerName() { + try { + var e = Lampa.Controller.enabled(); + return (e && e.name) || ""; + } catch (err) { + return ""; + } + } + + function rememberController() { + var name = controllerName(); + if ( + name && + name !== "select" && + name !== "keybord" && + name !== "settings_input" + ) + uiPrevController = name; + return uiPrevController; + } + + function restoreController(name) { + var target = name || uiPrevController || "content"; + + if (target === "content" && playerIsOpen()) target = "player"; + + try { + Lampa.Controller.toggle(target); + if (controllerName() === target) return; + } catch (err) {} + + if (playerIsOpen()) { + try { + Lampa.Controller.toggle("player"); + } catch (err) {} + if (controllerName() === "player") return; + } + + try { + Lampa.Controller.toggle("content"); + } catch (err) {} + } + + function askInput(params, done) { + Lampa.Input.edit( + { + title: params.title, + value: params.value || "", + free: true, + nosave: true, + }, + function (value) { + restoreController(); + done(value); + }, + ); + } + + function closeSettings() { + var guard = 0; + while ($("body").hasClass("settings--open") && guard++ < 4) { + var name = controllerName(); + if (name !== "settings" && name !== "settings_component") break; + try { + Lampa.Controller.back(); + } catch (err) { + break; + } + } + } + + function playerIsOpen() { + try { + if (Lampa.Player && typeof Lampa.Player.opened === "function") + return !!Lampa.Player.opened(); + } catch (err) {} + return !!document.querySelector(".player"); + } + + var SHA_K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ]; + + function sha256hex(str) { + var msg = unescape(encodeURIComponent(str)); + var len = msg.length; + var bitLen = len * 8; + var blocks = (((len + 8) >> 6) + 1) * 16; + var w = []; + var i, j; + + for (i = 0; i < blocks; i++) w[i] = 0; + for (i = 0; i < len; i++) + w[i >> 2] |= (msg.charCodeAt(i) & 0xff) << (24 - (i % 4) * 8); + w[len >> 2] |= 0x80 << (24 - (len % 4) * 8); + w[blocks - 1] = bitLen; + + var H = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, + 0x1f83d9ab, 0x5be0cd19, + ]; + var m = []; + + function rr(v, n) { + return (v >>> n) | (v << (32 - n)); + } + + for (i = 0; i < blocks; i += 16) { + var a = H[0], + b = H[1], + c = H[2], + d = H[3], + e = H[4], + f = H[5], + g = H[6], + h = H[7]; + + for (j = 0; j < 64; j++) { + if (j < 16) { + m[j] = w[i + j] | 0; + } else { + var s0 = rr(m[j - 15], 7) ^ rr(m[j - 15], 18) ^ (m[j - 15] >>> 3); + var s1 = rr(m[j - 2], 17) ^ rr(m[j - 2], 19) ^ (m[j - 2] >>> 10); + m[j] = (m[j - 16] + s0 + m[j - 7] + s1) | 0; + } + var S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25); + var ch = (e & f) ^ (~e & g); + var t1 = (h + S1 + ch + SHA_K[j] + m[j]) | 0; + var S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22); + var mj = (a & b) ^ (a & c) ^ (b & c); + var t2 = (S0 + mj) | 0; + + h = g; + g = f; + f = e; + e = (d + t1) | 0; + d = c; + c = b; + b = a; + a = (t1 + t2) | 0; + } + + H[0] = (H[0] + a) | 0; + H[1] = (H[1] + b) | 0; + H[2] = (H[2] + c) | 0; + H[3] = (H[3] + d) | 0; + H[4] = (H[4] + e) | 0; + H[5] = (H[5] + f) | 0; + H[6] = (H[6] + g) | 0; + H[7] = (H[7] + h) | 0; + } + + var out = ""; + for (i = 0; i < 8; i++) { + for (j = 3; j >= 0; j--) { + var byte = (H[i] >>> (j * 8)) & 0xff; + out += (byte < 16 ? "0" : "") + byte.toString(16); + } + } + return out; + } + + function roomChannel(roomId, password) { + return ( + ROOM_PREFIX + + sha256hex((roomId || "").toUpperCase() + "|" + (password || "")).substr( + 0, + 24, + ) + ); + } + + function newRoomId() { + var chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + var id = ""; + for (var i = 0; i < 6; i++) + id += chars.charAt(Math.floor(Math.random() * chars.length)); + return id; + } + + function Sock(opts) { + var self = this; + this.channel = opts.channel; + this.alias = opts.alias || ""; + this.echo = !!opts.echo; + this.onEvent = opts.onEvent || function () {}; + this.reconnect = !!opts.reconnect; + this.ws = null; + this.uid = null; + this.members = []; + this.closed = false; + this.retryTimer = null; + + this.url = function () { + var q = "announce=true&list=true"; + if (self.echo) q += "&echo=true"; + if (self.alias) q += "&as=" + encodeURIComponent(self.alias); + return getRelay() + encodeURIComponent(self.channel) + "?" + q; + }; + + this.open = function () { + if (self.closed) return; + if (self.ws && (self.ws.readyState === 0 || self.ws.readyState === 1)) + return; + + try { + self.ws = new WebSocket(self.url()); + } catch (err) { + self.scheduleRetry(); + return; + } + + self.ws.onopen = function () { + console.log("[WatchTogether] ws open", self.channel); + self.onEvent({ kind: "open" }); + }; + + self.ws.onmessage = function (e) { + var d; + try { + d = JSON.parse(e.data); + } catch (err) { + return; + } + if (!d) return; + + if (d.type === "join") { + if (d.self) { + self.uid = d.uid; + self.members = []; + var list = d.users || []; + for (var i = 0; i < list.length; i++) { + if (!self.findMember(list[i].uid)) + self.members.push({ + uid: list[i].uid, + alias: list[i].alias || "", + }); + } + if (!self.findMember(self.uid)) + self.members.push({ uid: self.uid, alias: self.alias }); + self.onEvent({ + kind: "ready", + date: d.date, + total: d.total, + members: self.members, + }); + } else { + if (!self.findMember(d.uid)) + self.members.push({ uid: d.uid, alias: d.alias || "" }); + self.onEvent({ + kind: "join", + date: d.date, + uid: d.uid, + alias: d.alias || "", + total: d.total, + }); + } + return; + } + + if (d.type === "leave") { + for (var k = self.members.length - 1; k >= 0; k--) { + if (self.members[k].uid === d.uid) self.members.splice(k, 1); + } + self.onEvent({ + kind: "leave", + date: d.date, + uid: d.uid, + alias: d.alias || "", + total: d.total, + }); + return; + } + + if (d.type === "error") { + console.log("[WatchTogether] relay error:", d.message); + self.onEvent({ kind: "error", date: d.date, text: d.message }); + return; + } + + if (d.message && typeof d.message === "object") { + self.onEvent({ + kind: "msg", + date: d.date, + uid: d.uid, + alias: d.alias || "", + msg: d.message, + mine: d.uid === self.uid, + }); + } + }; + + self.ws.onclose = function () { + console.log( + "[WatchTogether] ws close", + self.channel, + self.closed ? "(by us)" : "(remote)", + ); + self.uid = null; + self.members = []; + self.onEvent({ kind: "close" }); + self.scheduleRetry(); + }; + + self.ws.onerror = function () {}; + }; + + this.scheduleRetry = function () { + if (!self.reconnect || self.closed) return; + if (self.retryTimer) clearTimeout(self.retryTimer); + self.retryTimer = setTimeout(function () { + self.retryTimer = null; + self.open(); + }, RECONNECT_MS); + }; + + this.findMember = function (uid) { + for (var i = 0; i < self.members.length; i++) + if (self.members[i].uid === uid) return self.members[i]; + return null; + }; + + this.alive = function () { + return !!self.ws && self.ws.readyState === 1; + }; + + this.send = function (obj) { + if (!self.alive()) return false; + try { + self.ws.send(JSON.stringify(obj)); + return true; + } catch (err) { + return false; + } + }; + + this.close = function () { + self.closed = true; + if (self.retryTimer) { + clearTimeout(self.retryTimer); + self.retryTimer = null; + } + if (self.ws) { + try { + self.ws.close(); + } catch (err) {} + self.ws = null; + } + }; + } + + var room = null; + var lobbyHost = null; + var inRoom = false; + var joining = false; + var joinTimer = null; + + var currentRoomId = null; + var currentRoomPassword = ""; + var currentRoomName = ""; + var currentRoomOwner = null; + var currentRoomMeta = { + title: "", + poster: "", + url: "", + tmdb_id: 0, + source: "", + type: "", + }; + + var pidByUid = {}; + + var episodeSwitchAt = 0; + var roomAliveAt = 0; + + function markEpisodeSwitch() { + episodeSwitchAt = Date.now(); + } + function clearEpisodeSwitch() { + episodeSwitchAt = 0; + } + function isEpisodeSwitching() { + return ( + episodeSwitchAt > 0 && Date.now() - episodeSwitchAt < EPISODE_SWITCH_MS + ); + } + + var serverTimeOffset = 0; + var pingSamples = []; + var echoSeq = 0; + var echoPending = {}; + var pingTimer = null; + var watchdogTimer = null; + + var isSystemSyncing = false; + var lastUserActionTime = 0; + var rewindUntil = 0; + var lastHardSeekAt = 0; + var knownPids = {}; + + var holdActive = false; + var holdPosition = 0; + var holdWaiting = {}; + var holdReadySent = false; + var holdTimer = null; + var holdBufferSeen = -1; + var holdBufferAt = 0; + var holdNudgeDone = false; + var initialSyncLock = false; + var targetInitialState = null; + var expectedState = { seek: -1, play: false, pause: false }; + + var lastStreamUrl = null; + var lastStreamTitle = null; + var pendingShareCard = null; + var pendingShareAt = 0; + var lastViewedCard = null; + + function serverNow() { + return Date.now() + serverTimeOffset; + } + + function iAmHost() { + return !!currentRoomOwner && currentRoomOwner === pid; + } + + function memberCount() { + return room ? room.members.length : 0; + } + + function getTmdbId(card) { + if (!card) return 0; + return card.id || card.tmdb_id || 0; + } + + function getCardPoster(card) { + if (!card) return ""; + if (card.poster_path) + return "https://image.tmdb.org/t/p/w300" + card.poster_path; + return card.img || card.background_image || ""; + } + + function expectedPositionNow(state, basePosition, atServerTime) { + if (state !== "playing" || !atServerTime || atServerTime <= 0) + return basePosition; + var elapsedSec = (serverNow() - atServerTime) / 1000; + if (elapsedSec < 0 || elapsedSec > 3600) return basePosition; + return basePosition + elapsedSec; + } + + function roomSend(obj) { + if (!room || !room.alive()) return false; + obj.u = pid; + obj.k = ++echoSeq; + echoPending[obj.k] = Date.now(); + return room.send(obj); + } + + function handleEcho(key, serverDate) { + var t0 = echoPending[key]; + if (!t0) return; + delete echoPending[key]; + + var t1 = Date.now(); + var rtt = t1 - t0; + var offset = serverDate + rtt / 2 - t1; + + pingSamples.push({ offset: offset, rtt: rtt }); + if (pingSamples.length > 8) pingSamples.shift(); + + var best = pingSamples[0]; + for (var i = 1; i < pingSamples.length; i++) + if (pingSamples[i].rtt < best.rtt) best = pingSamples[i]; + serverTimeOffset = best.offset; + } + + function startClockTimers() { + stopClockTimers(); + pingTimer = setInterval(function () { + if (room && room.alive()) roomSend({ t: "ping" }); + }, PING_INTERVAL_MS); + + watchdogTimer = setInterval(function () { + if (!room || !room.alive()) return; + var now = Date.now(); + for (var key in echoPending) { + if (!echoPending.hasOwnProperty(key)) continue; + if (now - echoPending[key] > ECHO_TIMEOUT_MS) { + console.log( + "[WatchTogether]", + lplog("echo watchdog timeout - forcing reconnect"), + ); + echoPending = {}; + try { + room.ws.close(); + } catch (err) {} + return; + } + } + }, 5000); + } + + function stopClockTimers() { + if (pingTimer) { + clearInterval(pingTimer); + pingTimer = null; + } + if (watchdogTimer) { + clearInterval(watchdogTimer); + watchdogTimer = null; + } + } + + function buildAd() { + return { + id: currentRoomId, + name: currentRoomName, + title: currentRoomMeta.title || "", + poster: currentRoomMeta.poster || "", + owner: getDisplayName(), + members: memberCount(), + pwd: currentRoomPassword ? 1 : 0, + tmdb: currentRoomMeta.tmdb_id || 0, + type: currentRoomMeta.type || "movie", + }; + } + + function startLobbyAgent() { + stopLobbyAgent(); + if (!isPublish()) return; + // Never advertise a passwordless room, even one we only joined or inherited as host: + // the lobby is a public relay, so anyone reading it could walk straight in. + if (!currentRoomPassword) return; + + lobbyHost = new Sock({ + channel: LOBBY_CHANNEL, + alias: "", + echo: false, + reconnect: true, + onEvent: function (e) { + if (e.kind !== "msg" || e.mine) return; + if (!e.msg || e.msg.t !== "who") return; + if (!inRoom || !currentRoomId) return; + + var ad = buildAd(); + setTimeout( + function () { + if (lobbyHost && inRoom) lobbyHost.send({ t: "ad", r: ad }); + }, + Math.floor(Math.random() * 350) + 50, + ); + }, + }); + lobbyHost.open(); + } + + function stopLobbyAgent() { + if (lobbyHost) { + lobbyHost.close(); + lobbyHost = null; + } + } + + function discoverRooms(done) { + var found = {}; + var finished = false; + + var probe = new Sock({ + channel: LOBBY_CHANNEL, + alias: "", + echo: false, + reconnect: false, + onEvent: function (e) { + if (e.kind === "ready") { + probe.send({ t: "who" }); + return; + } + if ( + e.kind === "msg" && + !e.mine && + e.msg && + e.msg.t === "ad" && + e.msg.r && + e.msg.r.id + ) { + found[e.msg.r.id] = e.msg.r; + } + }, + }); + probe.open(); + + setTimeout(function () { + if (finished) return; + finished = true; + probe.close(); + + var list = []; + for (var id in found) { + if (!found.hasOwnProperty(id)) continue; + list.push(found[id]); + } + list.sort(function (a, b) { + return (b.members || 0) - (a.members || 0); + }); + done(list); + }, LOBBY_COLLECT_MS); + } + + var ICON_FILM = + ''; + + function ensureRoomStyle() { + if (document.getElementById("watchtogether-room-style")) return; + + var style = document.createElement("style"); + + style.id = "watchtogether-room-style"; + style.textContent = + ".selectbox-item__icon > .watchtogether-poster{width:3.4em;height:5.1em;border-radius:0.3em;" + + "background-color:#3e3e3e;object-fit:cover;}" + + ".watchtogether-poster--empty{display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.4);}" + + ".watchtogether-poster--empty > svg{width:1.6em;height:1.6em;}"; + + document.head.appendChild(style); + } + + function roomPosterStub() { + return ( + '
' + + ICON_FILM + + "
" + ); + } + + function roomPoster(r) { + if (!r.poster) return roomPosterStub(); + return ''; + } + + function bindPosterFallback(item) { + item.find("img.watchtogether-poster").on("error", function () { + $(this).replaceWith(roomPosterStub()); + }); + } + + var browserBusy = false; + + function openRoomBrowser() { + if (browserBusy) return; + browserBusy = true; + + rememberController(); + dropStaleRoom(); + + Lampa.Noty.show(T.searching); + + discoverRooms(function (rooms) { + browserBusy = false; + + rememberController(); + + var items = []; + + if (inRoom) { + items.push({ + title: + '✕ ' + + T.leave_btn + + " [" + + safe(currentRoomId || "") + + "]", + action: "leave", + }); + } + + items.push({ + title: '+ ' + T.create_btn + "", + action: "create", + }); + items.push({ + title: '# ' + T.join_code_btn + "", + action: "code", + }); + + if (rooms.length) ensureRoomStyle(); + + for (var i = 0; i < rooms.length; i++) { + var r = rooms[i]; + var lock = r.pwd ? " 🔒" : ""; + var line2 = []; + + if (r.title) line2.push(safe(r.title)); + line2.push(T.label_owner + ": " + safe(r.owner || "")); + line2.push(T.label_members + ": " + (r.members || 0)); + + items.push({ + template: "selectbox_icon", + icon: roomPoster(r), + title: + "" + + safe(r.name || r.id) + + ' [' + + safe(r.id) + + "]" + + lock, + subtitle: line2.join(" · "), + onDraw: bindPosterFallback, + room: r, + }); + } + + if (rooms.length === 0) { + items.push({ + title: '' + T.empty_list + "", + disabled: true, + }); + } + + Lampa.Select.show({ + title: T.head_title, + items: items, + onSelect: function (a) { + restoreController(); + + if (a.disabled) return; + if (a.action === "leave") return manualLeaveRoom(); + if (a.action === "create") return askCreateRoom(); + if (a.action === "code") return askRoomCode(); + if (a.room) return tryJoinRoom(a.room); + }, + onBack: function () { + restoreController(); + }, + }); + }); + } + + function manualLeaveRoom() { + if (!inRoom && !joining) return; + leaveRoom(true); + Lampa.Noty.show(T.left_ok); + } + + function askRoomCode() { + askInput({ title: T.input_room_code, value: "" }, function (val) { + var id = (val || "").toString().trim().toUpperCase(); + if (!id) return; + tryJoinRoom({ id: id, pwd: 1, name: id }); + }); + } + + function tryJoinRoom(r) { + dropStaleRoom(); + + if (inRoom) { + Lampa.Noty.show( + T.already_in_room(currentRoomName || currentRoomId || ""), + ); + return; + } + + if (!r.pwd) return joinRoom(r.id, "", r.name || r.id); + + var prefill = isUsePassword() ? getDefaultPassword() : ""; + askInput({ title: T.input_join_password, value: prefill }, function (val) { + joinRoom(r.id, val || "", r.name || r.id); + }); + } + + function resetRoomState() { + stopClockTimers(); + + $(".watchtogether-room-badge").remove(); + + var vid = getVideo(); + if (vid) clearRateAdjust(vid); + + if (expectedSeekTimer) { + clearTimeout(expectedSeekTimer); + expectedSeekTimer = null; + } + clearPendingSync(); + expectedState = { seek: -1, play: false, pause: false }; + initialSyncLock = false; + targetInitialState = null; + isSystemSyncing = false; + rewindUntil = 0; + holdActive = false; + holdPosition = 0; + holdWaiting = {}; + holdReadySent = false; + resetHoldProgress(); + if (holdTimer) { + clearTimeout(holdTimer); + holdTimer = null; + } + inRoom = false; + joining = false; + currentRoomId = null; + currentRoomPassword = ""; + currentRoomName = ""; + currentRoomOwner = null; + currentRoomMeta = { + title: "", + poster: "", + url: "", + tmdb_id: 0, + source: "", + type: "", + }; + lastStreamUrl = stripRoomParam(lastStreamUrl); + pidByUid = {}; + knownPids = {}; + echoPending = {}; + pingSamples = []; + lastHardSeekAt = 0; + clearEpisodeSwitch(); + roomAliveAt = 0; + + createPending = false; + if (createTimer) { + clearTimeout(createTimer); + createTimer = null; + } + if (joinTimer) { + clearTimeout(joinTimer); + joinTimer = null; + } + } + + function dropStaleRoom() { + if ((inRoom || joining) && !playerIsOpen()) { + console.log( + "[WatchTogether]", + lplog("stale room detected (player closed) - leaving"), + ); + leaveRoom(true); + return true; + } + return false; + } + + function leaveRoom(sendBye) { + if (inRoom || joining) + console.log("[WatchTogether]", lplog("leave room", currentRoomId || "")); + if (room) { + if (sendBye && room.alive()) roomSend({ t: "bye" }); + room.close(); + room = null; + } + stopLobbyAgent(); + resetRoomState(); + } + + function stripRoomParam(url) { + if (!url) return url; + return url.replace(/([?&])room=[^&]*&?/, "$1").replace(/[?&]$/, ""); + } + + function withRoomParam(url) { + if (!url || !currentRoomId || url.search(/[?&]room=/) !== -1) return url; + var data = btoa( + unescape( + encodeURIComponent(currentRoomId + ":" + (currentRoomPassword || "")), + ), + ); + return ( + url + + (url.indexOf("?") === -1 ? "?" : "&") + + "room=" + + encodeURIComponent(data) + ); + } + + function inviteLink() { + return withRoomParam(getInvite()); + } + + function connectRoom(roomId, password, onReady) { + if (room) { + room.close(); + room = null; + } + + currentRoomId = roomId; + currentRoomPassword = password || ""; + roomAliveAt = Date.now(); + + room = new Sock({ + channel: roomChannel(roomId, password), + alias: getDisplayName(), + echo: true, + reconnect: true, + onEvent: onRoomEvent, + }); + room._onReady = onReady; + room.open(); + startClockTimers(); + } + + function androidRoomHandoff(roomId, password, name, meta) { + currentRoomId = roomId; + currentRoomPassword = password || ""; + currentRoomName = name || roomId; + currentRoomMeta = { + title: (meta && meta.title) || "", + poster: (meta && meta.poster) || "", + url: (meta && meta.url) || inviteLink(), + tmdb_id: (meta && meta.tmdb_id) || 0, + source: (meta && meta.source) || "", + type: (meta && meta.type) || "movie", + }; + + console.log( + "[WatchTogether] android player: room", + roomId, + "handed to player, no relay socket from lampa", + ); + + closeSettings(); + playRoomStream( + currentRoomMeta.url, + currentRoomMeta.title || currentRoomName, + currentRoomMeta.poster, + ); + } + + function joinRoom(roomId, password, fallbackName) { + if (playerLaunch === "android") + return androidRoomHandoff(roomId, password, fallbackName, null); + + Lampa.Noty.show(T.connecting); + + joining = true; + currentRoomName = fallbackName || roomId; + + connectRoom(roomId, password, function (ev) { + console.log("[WatchTogether] join: channel ready, total", ev.total); + roomSend({ + t: "hello", + n: getDisplayName(), + cd: iAmHost() && isPublish() ? currentRoomId : undefined, + }); + + if (ev.total <= 1) { + if (joinTimer) clearTimeout(joinTimer); + joinTimer = setTimeout(failJoin, JOIN_EMPTY_TIMEOUT_MS); + } + }); + + if (joinTimer) clearTimeout(joinTimer); + joinTimer = setTimeout(failJoin, JOIN_TIMEOUT_MS); + } + + function failJoin() { + if (!joining) return; + console.log("[WatchTogether] join failed - no answer from room"); + Lampa.Noty.show(T.no_room); + leaveRoom(false); + } + + function extendJoinDeadline() { + if (!joining) return; + if (joinTimer) clearTimeout(joinTimer); + joinTimer = setTimeout(failJoin, JOIN_TIMEOUT_MS); + } + + function acceptRoomState(msg, atServerTime) { + if (joinTimer) { + clearTimeout(joinTimer); + joinTimer = null; + } + joining = false; + + if (!msg.url) { + Lampa.Noty.show(T.no_stream); + leaveRoom(true); + return; + } + + inRoom = true; + roomAliveAt = Date.now(); + currentRoomName = msg.rn || currentRoomName; + currentRoomOwner = msg.own || null; + if (typeof msg.sp === "number" && msg.sp >= 0.25 && msg.sp <= 4.0) + roomSpeed = msg.sp; + currentRoomMeta = { + title: msg.ti || "", + poster: msg.po || "", + url: msg.url, + tmdb_id: msg.tm || 0, + source: msg.src || "", + type: msg.ty || "movie", + }; + + var roomState = msg.s === "playing" ? "playing" : "paused"; + var roomPos = msg.p || 0; + + var needsInitialSync = roomState === "playing" || roomPos > 0.5; + + initialSyncLock = needsInitialSync; + targetInitialState = needsInitialSync + ? { + state: roomState, + position: roomPos, + atServerTime: atServerTime, + } + : null; + + Lampa.Noty.show(T.join_ok(currentRoomName)); + + closeSettings(); + + playRoomStream( + currentRoomMeta.url, + currentRoomMeta.title || currentRoomName, + currentRoomMeta.poster || "", + ); + } + + function askCreateRoom() { + if (lastStreamUrl) { + Lampa.Select.show({ + title: T.create_btn, + items: [ + { + title: + T.create_from_player + " (" + safe(lastStreamTitle || "") + ")", + share: true, + }, + { title: T.input_url, share: false }, + ], + onSelect: function (a) { + restoreController(); + + if (a.share) + askRoomDetails({ + stream_url: lastStreamUrl, + title: lastStreamTitle || "", + }); + else promptStreamUrl(); + }, + onBack: function () { + restoreController(); + }, + }); + } else { + promptStreamUrl(); + } + } + + function promptStreamUrl() { + askInput({ title: T.input_url, value: "" }, function (val) { + if (!val) return Lampa.Noty.show(T.need_url); + askRoomDetails({ stream_url: val, title: "" }); + }); + } + + function askRoomDetails(seed) { + askInput( + { + title: T.input_room_name, + value: seed.title || "Room " + Math.floor(Math.random() * 1000), + }, + function (name) { + if (!name) name = "Room"; + seed.name = name; + + askRoomPassword(seed, function () { + createRoom(seed, false); + }); + }, + ); + } + + // Publishing implies a password, so ask for one whenever either switch wants it. + // An empty answer is allowed - the room is then simply kept out of the lobby list. + function askRoomPassword(seed, done) { + if (!isUsePassword() && !isPublish()) { + seed.password = ""; + return done(); + } + + askInput( + { title: T.input_password, value: getDefaultPassword() }, + function (pwd) { + seed.password = pwd || ""; + done(); + }, + ); + } + + var createPending = false; + var createTimer = null; + function createRoom(seed, hostAlreadyPlaying) { + if (createPending) return; + + if (!hostAlreadyPlaying) dropStaleRoom(); + + if (inRoom) { + Lampa.Noty.show( + T.already_in_room(currentRoomName || currentRoomId || ""), + ); + return; + } + if (!seed.stream_url) { + Lampa.Noty.show(T.need_url); + return; + } + + createPending = true; + + var id = newRoomId(); + + currentRoomName = seed.name || "Room-" + id; + currentRoomOwner = pid; + currentRoomMeta = { + title: seed.title || "", + poster: seed.poster || "", + url: seed.stream_url, + tmdb_id: seed.tmdb_id || 0, + source: seed.source || "", + type: seed.type || "movie", + }; + + connectRoom(id, seed.password || "", function () { + createPending = false; + if (createTimer) { + clearTimeout(createTimer); + createTimer = null; + } + + inRoom = true; + joining = false; + roomAliveAt = Date.now(); + + initialSyncLock = false; + targetInitialState = null; + + startLobbyAgent(); + + // One notification, not a queue: each Noty replaces the previous one, so the + // name, the code and the unlisted warning have to travel together or get eaten. + var lines = [T.create_ok(currentRoomName), T.room_code(id)]; + if (!currentRoomPassword && isPublish()) lines.push(T.publish_needs_pwd); + // Noty renders with .html() and its time is in ms - \n would collapse, 8 would blink. + Lampa.Noty.show(lines.join("
"), { time: 8000 }); + + var vid = getVideo(); + + if (hostAlreadyPlaying && playerIsOpen() && vid) { + roomSend({ + t: "sync", + s: vid.paused ? "paused" : "playing", + p: vid.currentTime || 0, + }); + } else { + if (hostAlreadyPlaying) + console.log( + "[WatchTogether]", + lplog("player was expected open but is not - starting it"), + ); + + closeSettings(); + + playRoomStream( + currentRoomMeta.url, + currentRoomMeta.title || currentRoomName, + currentRoomMeta.poster || "", + ); + } + }); + + if (createTimer) clearTimeout(createTimer); + createTimer = setTimeout(function () { + createTimer = null; + if (createPending) { + createPending = false; + Lampa.Noty.show(T.create_fail); + leaveRoom(false); + } + }, JOIN_TIMEOUT_MS); + } + + function autoCreateRoomFromPending(card, streamUrl) { + var title = + card.title || + card.name || + card.original_title || + card.original_name || + ""; + var type = + card.name || card.number_of_seasons || card.first_air_date + ? "tv" + : "movie"; + + var seed = { + stream_url: streamUrl, + title: title, + poster: getCardPoster(card), + tmdb_id: getTmdbId(card), + source: card.source || "tmdb", + type: type, + name: title || "Room " + Math.floor(Math.random() * 1000), + password: "", + }; + + askRoomPassword(seed, function () { + createRoom(seed, true); + }); + } + + function onRoomEvent(e) { + if (e.kind === "ready") { + echoPending = {}; + + if (room && room._onReady) { + var cb = room._onReady; + room._onReady = null; + cb(e); + return; + } + + if (inRoom) { + roomSend({ + t: "hello", + n: getDisplayName(), + cd: iAmHost() && isPublish() ? currentRoomId : undefined, + }); + if (iAmHost()) sendHostState(); + } + updateRoomBadge(); + return; + } + + if (e.kind === "close") { + updateRoomBadge(); + return; + } + + if (e.kind === "join") { + updateRoomBadge(); + return; + } + + if (e.kind === "leave") { + onMemberLeave(e); + return; + } + + if (e.kind !== "msg" || !e.msg) return; + + if (e.mine) { + if (e.msg.k) handleEcho(e.msg.k, e.date); + return; + } + + var m = e.msg; + if (m.u) pidByUid[e.uid] = m.u; + + if (m.u === pid && m.t === "hello") { + Lampa.Noty.show(T.kicked); + leaveRoom(false); + return; + } + + if (m.t === "hello") { + if (!inRoom) return; + Lampa.Noty.show(T.notice_joined(m.n || e.alias)); + + var seenAt = m.u ? knownPids[m.u] : 0; + var reconnected = seenAt && Date.now() - seenAt < RECONNECT_HELLO_MS; + if (m.u) knownPids[m.u] = Date.now(); + + console.log( + "[WatchTogether] hello from", + m.u, + reconnected ? "(reconnect)" : "(new)", + ); + + if (iAmHost() && !reconnected) startJoinHold(m.u); + + setTimeout( + function () { + if (!inRoom) return; + roomSend({ t: "me", n: getDisplayName() }); + if (iAmHost()) sendHostState(); + }, + Math.floor(Math.random() * 250) + 50, + ); + updateRoomBadge(); + return; + } + + if (m.t === "hold") { + if (!inRoom && !joining) return; + if (currentRoomOwner && m.u !== currentRoomOwner) return; + extendJoinDeadline(); + applyRoomSpeed(m); + applyHold(m.p || 0); + return; + } + + if (m.t === "ready") { + markHoldReady(m.u); + return; + } + + if (m.t === "go") { + if (!inRoom && !joining) return; + if (currentRoomOwner && m.u !== currentRoomOwner) return; + applyRoomSpeed(m); + releaseHold(typeof m.p === "number" ? m.p : holdPosition); + return; + } + + if (m.t === "me") { + updateRoomBadge(); + return; + } + + if (m.t === "state") { + if (joining) acceptRoomState(m, e.date); + return; + } + if (m.t === "host") { + currentRoomOwner = m.u; + clearPendingSync(); + Lampa.Noty.show(T.notice_host_changed(m.n || e.alias)); + return; + } + + if (m.t === "url") { + if (!inRoom || !m.url) return; + if (currentRoomOwner && m.u !== currentRoomOwner) return; + + currentRoomMeta.url = m.url; + currentRoomMeta.title = m.ti || currentRoomMeta.title; + markEpisodeSwitch(); + clearPendingSync(); + try { + playRoomStream(m.url, m.ti || currentRoomName, ""); + } catch (err) {} + return; + } + + if (m.t === "sync" || m.t === "act") { + if (!inRoom) return; + + if ( + m.t === "sync" && + (iAmHost() || (currentRoomOwner && m.u !== currentRoomOwner)) + ) + return; + + applyRoomSpeed(m); + + if (m.t === "act" && m.v) { + var text = formatNotice(m.v, m.n || e.alias); + if (text) Lampa.Noty.show(text); + } + + var state = m.s === "playing" ? "playing" : "paused"; + var position = m.p || 0; + + // вхідна пауза на кінці серії — це природне завершення відео у партнера, а не дія користувача + if (m.t === "act" && state === "paused") { + var d = getVideo() && getVideo().duration; + if (d && isFinite(d) && d > 0 && position >= d - 1.0) return; + } + + if (initialSyncLock) { + targetInitialState = { + state: state, + position: position, + atServerTime: e.date, + }; + return; + } + + var vid = getVideo(); + if (!vid) return; + + if (isRewinding() || holdActive) return; + if (Date.now() - lastUserActionTime < 2000) return; + // a seek of ours is waiting out the debounce - the heartbeat cannot know about it yet, + // so its position is stale by definition. explicit acts still get through + if (m.t === "sync" && pendingSeekBroadcast) return; + + // anything newer supersedes a deferred seek - never let a stale snapshot outlive it + pendingAct = null; + + isSystemSyncing = true; + applySync(vid, state, position, e.date); + setTimeout(function () { + isSystemSyncing = false; + }, 500); + + // an explicit seek that could not land (buffering / seek cooldown) is kept and retried + // instead of being dropped - dropping it lets our heartbeat drag the peer back. + // threshold matches applySync's hard seek, so a drift it chose to fix by playbackRate + // is not mistaken for a missed seek + if (m.t === "act" && m.v === "seeked") { + var target = expectedPositionNow(state, position, e.date); + if (Math.abs((vid.currentTime || 0) - target) > hardSeekThreshold()) { + pendingAct = { + state: state, + position: position, + atServerTime: e.date, + at: Date.now(), + until: Date.now() + PENDING_ACT_MAX_MS, + }; + console.log( + "[WatchTogether]", + "act seek deferred, target", + target.toFixed(2), + ); + } + } + return; + } + + if (m.t === "bye") { + updateRoomBadge(); + return; + } + } + + function sendHostState() { + roomSend({ + t: "state", + rn: currentRoomName, + own: currentRoomOwner || pid, + // the code in the clear lets the server re-announce this room in the + // lobby; withheld when publishing is off, so the room stays unlisted + cd: isPublish() ? currentRoomId : undefined, + url: currentRoomMeta.url, + ti: currentRoomMeta.title, + po: currentRoomMeta.poster, + tm: currentRoomMeta.tmdb_id, + src: currentRoomMeta.source, + ty: currentRoomMeta.type, + s: hostPlaybackState(), + p: hostPlaybackPosition(), + sp: roomSpeed, + }); + } + + function hostPlaybackState() { + var vid = getVideo(); + if (!vid) return "paused"; + return vid.paused ? "paused" : "playing"; + } + + function hostPlaybackPosition() { + var vid = getVideo(); + return vid ? vid.currentTime || 0 : 0; + } + + function onMemberLeave(e) { + if (!inRoom) { + updateRoomBadge(); + return; + } + + var leaverPid = pidByUid[e.uid]; + delete pidByUid[e.uid]; + + Lampa.Noty.show(T.notice_left(e.alias || "")); + updateRoomBadge(); + + if (leaverPid && holdWaiting[leaverPid]) markHoldReady(leaverPid); + + var hostGone = currentRoomOwner && leaverPid === currentRoomOwner; + if (!hostGone) return; + + setTimeout(function () { + if (!inRoom || !room || !room.uid) return; + if (hostStillPresent()) return; + + var uids = [room.uid]; + for (var i = 0; i < room.members.length; i++) { + if (room.members[i].uid !== room.uid) uids.push(room.members[i].uid); + } + uids.sort(); + + if (uids[0] === room.uid) { + currentRoomOwner = pid; + clearPendingSync(); + roomSend({ t: "host", n: getDisplayName() }); + Lampa.Noty.show(T.notice_host_changed(getDisplayName())); + startLobbyAgent(); + } + }, 500); + } + + function hostStillPresent() { + if (!currentRoomOwner) return false; + if (currentRoomOwner === pid) return true; + if (!room) return false; + for (var i = 0; i < room.members.length; i++) { + if (pidByUid[room.members[i].uid] === currentRoomOwner) return true; + } + return false; + } + + function formatNotice(verb, who) { + if (!who) return ""; + if (verb === "joined") return T.notice_joined(who); + if (verb === "left") return T.notice_left(who); + if (verb === "paused") return T.notice_paused(who); + if (verb === "resumed" || verb === "playing") return T.notice_resumed(who); + if (verb === "seeked") return T.notice_seeked(who); + if (verb === "host_changed") return T.notice_host_changed(who); + return who + " · " + verb; + } + + function getVideo() { + var vid = null; + if (typeof Lampa.PlayerVideo !== "undefined" && Lampa.PlayerVideo.video) + vid = Lampa.PlayerVideo.video(); + if (!vid) + vid = + document.querySelector(".player-video__display video") || + document.querySelector(".player video") || + document.querySelector("video"); + return vid; + } + + function roomMarker() { + if (!currentRoomId) return ""; + try { + return btoa( + unescape( + encodeURIComponent(currentRoomId + ":" + (currentRoomPassword || "")), + ), + ); + } catch (err) { + return ""; + } + } + + function playRoomStream(url, title, poster) { + console.log( + "[WatchTogether]", + "start room stream via", + playerLaunch, + url ? url.substr(0, 60) : "", + ); + + if (Lampa.Player.runas) Lampa.Player.runas(playerLaunch); + + Lampa.Player.play({ + url: url, + title: title || "", + poster: poster || "", + launch_player: playerLaunch, + }); + } + + function pauseVideo(vid) { + if (typeof Lampa.PlayerVideo !== "undefined" && Lampa.PlayerVideo.pause) + Lampa.PlayerVideo.pause(); + else if (vid) vid.pause(); + } + + function playVideo(vid, onFail) { + if (typeof Lampa.PlayerVideo !== "undefined" && Lampa.PlayerVideo.play) + return Lampa.PlayerVideo.play(); + if (!vid) return; + var p = vid.play(); + if (p && p["catch"]) + p["catch"](function () { + if (onFail) onFail(); + }); + } + + function bufferedAhead(vid, position) { + var ranges; + + try { + ranges = vid.buffered; + } catch (err) { + return NO_RANGES; + } + if (!ranges || !ranges.length) return NO_RANGES; + + for (var i = 0; i < ranges.length; i++) { + if (position >= ranges.start(i) - 1 && position <= ranges.end(i)) { + return ranges.end(i) - position; + } + } + + return OUT_OF_RANGE; + } + + function holdReadyNow(vid) { + if (!vid) return false; + + var now = Date.now(); + var ahead = bufferedAhead(vid, holdPosition); + + if (ahead > holdBufferSeen + 0.25) { + holdBufferSeen = ahead; + holdBufferAt = now; + } + + if (ahead === NO_RANGES) return vid.readyState >= 3; + + if (ahead >= HOLD_BUFFER_S) return true; + if (vid.readyState >= 4 && ahead > 0) return true; + + var idle = now - (holdBufferAt || now); + + if (ahead >= HOLD_MIN_BUFFER_S && idle > HOLD_STALL_MS) { + console.log( + "[WatchTogether]", + "hold: buffer stopped growing at", + ahead.toFixed(2) + "s - accepting", + ); + return true; + } + + if (!holdNudgeDone && idle > HOLD_NUDGE_MS && vid.readyState >= 1) { + holdNudgeDone = true; + console.log( + "[WatchTogether]", + "hold: nudging loader, ahead=" + + ahead.toFixed(2) + + " pos=" + + (vid.currentTime || 0).toFixed(2), + ); + setExpectedSeek(holdPosition); + vid.currentTime = holdPosition + 0.05; + } + + return false; + } + + function resetHoldProgress() { + holdBufferSeen = -1; + holdBufferAt = Date.now(); + holdNudgeDone = false; + } + + function startJoinHold(newcomerPid) { + if (!inRoom || !iAmHost()) return; + + var vid = getVideo(); + + if (!holdActive) { + holdActive = true; + holdPosition = vid ? vid.currentTime || 0 : 0; + holdWaiting = {}; + holdReadySent = false; + resetHoldProgress(); + + if (vid && !vid.paused) { + expectPause(); + pauseVideo(vid); + } + + Lampa.Noty.show(T.notice_hold); + console.log("[WatchTogether]", "hold start at", holdPosition.toFixed(2)); + } + + if (newcomerPid && newcomerPid !== pid) holdWaiting[newcomerPid] = true; + + roomSend({ t: "hold", p: holdPosition, sp: roomSpeed }); + + if (holdTimer) clearTimeout(holdTimer); + holdTimer = setTimeout(finishHold, HOLD_MAX_MS); + } + + function markHoldReady(fromPid) { + if (!holdActive || !iAmHost()) return; + if (fromPid) delete holdWaiting[fromPid]; + checkHoldDone(); + } + + function checkHoldDone() { + if (!holdActive || !iAmHost()) return; + + for (var key in holdWaiting) { + if (holdWaiting.hasOwnProperty(key)) return; + } + + if (!holdReadyNow(getVideo())) return; + + finishHold(); + } + + function finishHold() { + if (!holdActive) return; + + var position = holdPosition; + + console.log("[WatchTogether]", "hold finish at", position.toFixed(2)); + roomSend({ t: "go", p: position, sp: roomSpeed }); + releaseHold(position); + } + + function releaseHold(position) { + var wasHolding = holdActive; + + holdActive = false; + holdWaiting = {}; + holdReadySent = false; + resetHoldProgress(); + if (holdTimer) { + clearTimeout(holdTimer); + holdTimer = null; + } + + initialSyncLock = false; + targetInitialState = null; + + if (wasHolding) Lampa.Noty.show(T.notice_go); + + var vid = getVideo(); + if (!vid) return; + + if ( + typeof position === "number" && + Math.abs((vid.currentTime || 0) - position) > 0.5 + ) { + setExpectedSeek(position); + vid.currentTime = position; + } + + if (vid.paused) { + expectPlay(); + playVideo(vid, function () { + expectedState.play = false; + }); + } + } + + function applyHold(position) { + holdActive = true; + holdPosition = position || 0; + holdReadySent = false; + resetHoldProgress(); + + Lampa.Noty.show(T.notice_hold); + console.log("[WatchTogether]", "hold received at", holdPosition.toFixed(2)); + + var vid = getVideo(); + if (!vid) return; + + if (!vid.paused) { + expectPause(); + pauseVideo(vid); + } + + if (Math.abs((vid.currentTime || 0) - holdPosition) > 1) { + setExpectedSeek(holdPosition); + vid.currentTime = holdPosition; + } + } + + function holdTick() { + if (!holdActive) return; + + if (iAmHost()) return checkHoldDone(); + + if (holdReadySent) return; + + var vid = getVideo(); + if (!holdReadyNow(vid)) return; + if (!roomSend({ t: "ready" })) return; + + holdReadySent = true; + console.log( + "[WatchTogether]", + "hold: ready sent, ahead=" + bufferedAhead(vid, holdPosition).toFixed(2), + ); + } + + function sendSync(state, verb) { + if (!inRoom || initialSyncLock || holdActive) return; + var vid = getVideo(); + if (!vid) return; + + if (verb) { + roomSend({ + t: "act", + s: state, + p: vid.currentTime || 0, + v: verb, + n: getDisplayName(), + sp: roomSpeed, + }); + } else { + roomSend({ t: "sync", s: state, p: vid.currentTime || 0, sp: roomSpeed }); + } + } + + var expectedSeekTimer = null; + var seekGuardUntil = 0; + var lastSeekBroadcastAt = 0; + var lastKnownPosition = 0; + var pendingSeekBroadcast = null; + var pendingAct = null; + + // both carry a position that only makes sense for the video that was playing when they were + // recorded - they must die with it, or they land on the next episode + function clearPendingSync() { + if (pendingSeekBroadcast) { + clearTimeout(pendingSeekBroadcast); + pendingSeekBroadcast = null; + } + pendingAct = null; + } + + function applyPendingAct() { + if (!pendingAct) return; + + if (Date.now() > pendingAct.until) { + console.log( + "[WatchTogether]", + "act seek given up after " + PENDING_ACT_MAX_MS + "ms", + ); + pendingAct = null; + return; + } + + // same gates the live path applies - a retry must never outrank a newer local action + if (lastUserActionTime > pendingAct.at) { + console.log( + "[WatchTogether]", + "act seek dropped, superseded by local action", + ); + pendingAct = null; + return; + } + if (!inRoom || initialSyncLock || isRewinding() || holdActive) return; + if (Date.now() - lastUserActionTime < 2000) return; + + var vid = getVideo(); + if (!vid) return; + if (videoBusy(vid)) return; + if (Date.now() - lastHardSeekAt <= HARD_SEEK_COOLDOWN_MS) return; + + var act = pendingAct; + pendingAct = null; + + console.log("[WatchTogether]", "act seek retry"); + isSystemSyncing = true; + applySync(vid, act.state, act.position, act.atServerTime); + setTimeout(function () { + isSystemSyncing = false; + }, 500); + } + + function setExpectedSeek(pos) { + expectedState.seek = pos; + seekGuardUntil = Date.now() + SEEK_GUARD_MS; + + if (expectedSeekTimer) clearTimeout(expectedSeekTimer); + expectedSeekTimer = setTimeout(function () { + expectedSeekTimer = null; + expectedState.seek = -1; + }, SEEK_GUARD_MS); + } + + function clearExpectedSeek() { + if (expectedSeekTimer) { + clearTimeout(expectedSeekTimer); + expectedSeekTimer = null; + } + expectedState.seek = -1; + seekGuardUntil = 0; + } + + function seekIsOurs() { + return Date.now() < seekGuardUntil; + } + + function broadcastUserSeek(vid) { + if ( + Math.abs((vid.currentTime || 0) - lastKnownPosition) < SEEK_MIN_JUMP_S + ) { + console.log( + "[WatchTogether]", + "seek ignored, position barely moved", + (vid.currentTime || 0).toFixed(2), + ); + return; + } + + if (isRewinding()) rewindUntil = Date.now() + 400; + lastKnownPosition = vid.currentTime || 0; + lastUserActionTime = Date.now(); + + // throttle defers the burst, never drops it - a dropped seek means the host + // heartbeat drags us back to its old position two seconds later + if (Date.now() - lastSeekBroadcastAt < SEEK_BROADCAST_MIN_MS) { + console.log( + "[WatchTogether]", + "seek broadcast deferred at", + (vid.currentTime || 0).toFixed(2), + ); + // debounce, not a periodic flush: a long scrub must end in ONE extra broadcast, + // not one every SEEK_BROADCAST_MIN_MS - each of those costs everyone a hard seek + if (pendingSeekBroadcast) clearTimeout(pendingSeekBroadcast); + pendingSeekBroadcast = setTimeout(function () { + pendingSeekBroadcast = null; + if (!inRoom || initialSyncLock || holdActive) return; + var v = getVideo(); + if (!v) return; + lastSeekBroadcastAt = Date.now(); + lastKnownPosition = v.currentTime || 0; + lastUserActionTime = Date.now(); + sendSync(v.paused ? "paused" : "playing", "seeked"); + }, SEEK_BROADCAST_MIN_MS); + return; + } + + if (pendingSeekBroadcast) { + clearTimeout(pendingSeekBroadcast); + pendingSeekBroadcast = null; + } + lastSeekBroadcastAt = Date.now(); + sendSync(vid.paused ? "paused" : "playing", "seeked"); + } + + function isRewinding() { + return Date.now() < rewindUntil; + } + + function onUserRewind() { + if (!inRoom || initialSyncLock) return; + rewindUntil = Date.now() + REWIND_GRACE_MS; + lastUserActionTime = Date.now(); + clearExpectedSeek(); + } + + function expectPlay() { + expectedState.play = true; + setTimeout(function () { + expectedState.play = false; + }, 500); + } + + function expectPause() { + expectedState.pause = true; + setTimeout(function () { + expectedState.pause = false; + }, 500); + } + + function clearRateAdjust(vid) { + if (vid._lp_rate_timeout) { + clearTimeout(vid._lp_rate_timeout); + vid._lp_rate_timeout = null; + } + vid._lp_correcting = false; + if (!canAdjustRate()) return; + if (vid.playbackRate !== roomSpeed) vid.playbackRate = roomSpeed; + } + + function videoBusy(vid) { + // a seek in flight is the loader working - same rule as buffering, do not fight it. + // a long jump can stay seeking for many seconds; correcting position mid-flight + // is what dragged the seeker back to the old spot + return !!vid._lp_buffering || vid.readyState < 3 || !!vid.seeking; + } + + var appleNative = (function () { + try { + return !!( + Lampa.Platform && + (Lampa.Platform.is("apple") || Lampa.Platform.is("apple_tv")) + ); + } catch (err) { + return false; + } + })(); + + function canAdjustRate() { + return !appleNative; + } + + function hardSeekThreshold() { + return appleNative ? SYNC_HARD_SEEK_APPLE_S : SYNC_HARD_SEEK_S; + } + + function applySync(vid, state, basePosition, atServerTime) { + if (vid.currentTime === undefined) return; + + var busy = videoBusy(vid); + + if (busy) { + clearRateAdjust(vid); + } else { + var expected = expectedPositionNow(state, basePosition, atServerTime); + var diff = vid.currentTime - expected; + var absDiff = Math.abs(diff); + + if (absDiff > hardSeekThreshold()) { + clearRateAdjust(vid); + vid._lp_correcting = false; + + if (Date.now() - lastHardSeekAt > HARD_SEEK_COOLDOWN_MS) { + lastHardSeekAt = Date.now(); + console.log( + "[WatchTogether]", + "hard seek", + vid.currentTime.toFixed(2), + "->", + expected.toFixed(2), + ); + setExpectedSeek(expected); + vid.currentTime = expected; + } else { + console.log( + "[WatchTogether]", + "hard seek skipped (cooldown), drift", + absDiff.toFixed(2), + ); + } + } else if ( + canAdjustRate() && + (vid._lp_correcting + ? absDiff > SYNC_TOLERANCE_S + : absDiff > SYNC_CORRECT_ON_S) + ) { + vid._lp_correcting = true; + + var raw = diff * SYNC_RATE_GAIN; + var offset = Math.max( + -SYNC_MAX_RATE_OFFSET, + Math.min(SYNC_MAX_RATE_OFFSET, raw), + ); + var newRate = roomSpeed - offset; + if (Math.abs(vid.playbackRate - newRate) > 0.005) + vid.playbackRate = newRate; + if (vid._lp_rate_timeout) clearTimeout(vid._lp_rate_timeout); + vid._lp_rate_timeout = setTimeout(function () { + vid._lp_rate_timeout = null; + vid._lp_correcting = false; + if (vid.playbackRate !== roomSpeed) vid.playbackRate = roomSpeed; + }, SYNC_RATE_RESET_MS); + } else { + vid._lp_correcting = false; + clearRateAdjust(vid); + } + } + + if (state === "paused" && !vid.paused) { + expectPause(); + clearRateAdjust(vid); + pauseVideo(vid); + return; + } + + if (state === "playing" && vid.paused && !busy) { + expectPlay(); + playVideo(vid, function () { + expectedState.play = false; + }); + } + } + + setInterval(function () { + if (inRoom || joining) { + if (playerIsOpen()) roomAliveAt = Date.now(); + else if (roomAliveAt && Date.now() - roomAliveAt > ROOM_ORPHAN_MS) { + console.log( + "[WatchTogether]", + lplog("room without player - auto leave"), + ); + leaveRoom(true); + } + } + + if (!inRoom) { + $(".watchtogether-room-badge").remove(); + return; + } + + // before the video check - a deferred act must be able to expire while there is no player, + // otherwise it silences the host heartbeat indefinitely + applyPendingAct(); + + var vid = getVideo(); + if (!vid) return; + updateRoomBadge(); + holdTick(); + + // our own seek that never produced a 'seeked' event (target equals current position) would + // otherwise keep the guard armed for the full SEEK_GUARD_MS and swallow the next user seek + if ( + expectedState.seek >= 0 && + !vid.seeking && + Math.abs((vid.currentTime || 0) - expectedState.seek) <= 0.5 + ) + clearExpectedSeek(); + + if (!seekIsOurs()) lastKnownPosition = vid.currentTime || 0; + + if (vid._lp_hooked) return; + vid._lp_hooked = true; + + // A joining client starts at the room's speed before the first sync lands. + if (canAdjustRate() && Math.abs((vid.playbackRate || 1) - roomSpeed) > 0.01) + vid.playbackRate = roomSpeed; + + var enforceInitial = function () { + if (!initialSyncLock || !targetInitialState) return; + var expected = expectedPositionNow( + targetInitialState.state, + targetInitialState.position, + targetInitialState.atServerTime, + ); + if (Math.abs(vid.currentTime - expected) > 1) { + setExpectedSeek(expected); + vid.currentTime = expected; + } + if (targetInitialState.state === "paused") { + expectPause(); + pauseVideo(vid); + } else { + expectPlay(); + playVideo(vid); + } + if (!vid._lp_enforce_timeout) { + vid._lp_enforce_timeout = setTimeout(function () { + vid._lp_enforce_timeout = null; + initialSyncLock = false; + targetInitialState = null; + expectedState.play = false; + expectedState.pause = false; + }, 3000); + } + }; + + if (vid.readyState >= 1) enforceInitial(); + else vid.addEventListener("loadedmetadata", enforceInitial); + vid.addEventListener("canplay", enforceInitial); + + vid.addEventListener("waiting", function () { + vid._lp_buffering = true; + console.log( + "[WatchTogether]", + "buffering start at", + (vid.currentTime || 0).toFixed(2), + ); + }); + vid.addEventListener("canplay", function () { + if (vid._lp_buffering) + console.log( + "[WatchTogether]", + "buffering end at", + (vid.currentTime || 0).toFixed(2), + ); + vid._lp_buffering = false; + }); + vid.addEventListener("playing", function () { + if (vid._lp_buffering) + console.log( + "[WatchTogether]", + "buffering end at", + (vid.currentTime || 0).toFixed(2), + ); + vid._lp_buffering = false; + }); + vid.addEventListener("error", function () { + console.log( + "[WatchTogether]", + "video error", + vid.error ? "code " + vid.error.code : "", + ); + }); + vid.addEventListener("stalled", function () { + console.log("[WatchTogether]", "video stalled"); + }); + + vid.addEventListener("ratechange", function () { + if ( + !inRoom || + initialSyncLock || + holdActive || + isSystemSyncing || + vid._lp_correcting + ) + return; + var newRate = vid.playbackRate || 1.0; + if (!(newRate >= 0.25 && newRate <= 4.0)) return; + if (Math.abs(newRate - roomSpeed) < 0.01) return; + roomSpeed = newRate; + lastUserActionTime = Date.now(); + // Only the host drives the room speed; guests keep it for themselves. + if (iAmHost()) { + roomSend({ + t: "act", + s: vid.paused ? "paused" : "playing", + p: vid.currentTime || 0, + n: getDisplayName(), + sp: roomSpeed, + }); + } + }); + + vid.addEventListener("play", function () { + if (initialSyncLock) { + if (targetInitialState && targetInitialState.state === "paused") { + pauseVideo(vid); + } + return; + } + var wasExpected = expectedState.play; + expectedState.play = false; + if (wasExpected) return; + + if (holdActive) { + console.log( + "[WatchTogether]", + "user started playback during hold - releasing it", + ); + lastUserActionTime = Date.now(); + + if (iAmHost()) { + finishHold(); + } else { + holdReadySent = true; + roomSend({ t: "ready" }); + releaseHold(holdPosition); + } + return; + } + + if (isRewinding()) { + lastUserActionTime = Date.now(); + return; + } + if (vid._lp_buffer_paused) { + vid._lp_buffer_paused = false; + return; + } + lastUserActionTime = Date.now(); + sendSync("playing", "resumed"); + }); + + vid.addEventListener("pause", function () { + clearRateAdjust(vid); + if (initialSyncLock) return; + var wasExpected = expectedState.pause; + expectedState.pause = false; + if (wasExpected) return; + + if (isRewinding()) { + lastUserActionTime = Date.now(); + return; + } + if (vid._lp_buffering || vid.readyState < 3) { + vid._lp_buffer_paused = true; + return; + } + if (vid.ended) return; + lastUserActionTime = Date.now(); + sendSync("paused", "paused"); + }); + + vid.addEventListener("seeked", function () { + if (!isSystemSyncing) clearRateAdjust(vid); + if (initialSyncLock) { + if (targetInitialState) { + var expected = expectedPositionNow( + targetInitialState.state, + targetInitialState.position, + targetInitialState.atServerTime, + ); + if (Math.abs(vid.currentTime - expected) > 1) { + setExpectedSeek(expected); + vid.currentTime = expected; + } + } + return; + } + if (isSystemSyncing) return; + + if (seekIsOurs()) { + console.log( + "[WatchTogether]", + "own seek settled at", + (vid.currentTime || 0).toFixed(2), + ); + clearExpectedSeek(); + lastKnownPosition = vid.currentTime || 0; + return; + } + + if (expectedState.seek !== -1) clearExpectedSeek(); + + broadcastUserSeek(vid); + }); + + // announce the jump when it STARTS, not when it settles: currentTime already holds the + // target, while a long jump can stay unsettled for many seconds - and an unannounced + // seeker gets dragged back by the host heartbeat the moment its buffer is ready + vid.addEventListener("seeking", function () { + // deliberately NOT gated on isSystemSyncing: that flag stays up 500ms after every + // applied sync, i.e. a quarter of the time at a 2s heartbeat, and it swallowed any + // user seek started in that window. seekIsOurs() is armed only when we actually + // moved the position ourselves, which is the real question here + if (initialSyncLock || seekIsOurs()) return; + broadcastUserSeek(vid); + }); + }, 1000); + + setInterval(function () { + if (!inRoom || initialSyncLock || isSystemSyncing || holdActive) return; + if (!iAmHost()) return; + if (expectedState.seek !== -1) return; + if (pendingAct) return; + var vid = getVideo(); + if (!vid) return; + if (vid.ended) return; + sendSync(vid.paused ? "paused" : "playing", null); + }, SYNC_HEARTBEAT_MS); + + var debugRefreshTimer = null; + + function buildDebugHtml() { + var wsState = + room && room.ws + ? ["CONNECTING", "OPEN", "CLOSING", "CLOSED"][room.ws.readyState] || + "UNKNOWN" + : "NO_WS"; + var lastRtt = pingSamples.length + ? pingSamples[pingSamples.length - 1].rtt + : "-"; + var bestRtt = "-"; + for (var i = 0; i < pingSamples.length; i++) { + if (bestRtt === "-" || pingSamples[i].rtt < bestRtt) + bestRtt = pingSamples[i].rtt; + } + var pendingCount = 0; + for (var k in echoPending) { + if (echoPending.hasOwnProperty(k)) pendingCount++; + } + + var pos = "-", + bufRdy = "-"; + var vid = getVideo(); + if (vid) { + pos = (vid.currentTime || 0).toFixed(2); + bufRdy = vid.readyState + (vid._lp_buffering ? "*" : ""); + } + + return [ + "
Relay: " + safe(wsState) + "
", + "
Room: " + + safe(currentRoomId || "-") + + (currentRoomPassword ? " 🔒" : "") + + "
", + "
Role: " + (iAmHost() ? "host" : "guest") + "
", + "
Members: " + memberCount() + "
", + "
Lobby: " + + (lobbyHost && lobbyHost.alive() ? "published" : "off") + + "
", + "
Clock offset: " + Math.round(serverTimeOffset) + " ms
", + "
RTT last/best: " + lastRtt + " / " + bestRtt + " ms
", + "
Echo pending: " + pendingCount + "
", + "
initialSyncLock: " + initialSyncLock + "
", + "
Video: " + pos + " s (ready " + bufRdy + ")
", + ].join(""); + } + + function updateRoomBadge() { + if (!inRoom || !currentRoomId) return; + + var nameContainer = $(".player-info__name, .player-panel__name"); + if (nameContainer.length && !$(".watchtogether-room-badge").length) { + var badge = $( + '
' + + '' + + '' + + "
", + ); + badge.on("mouseenter", function () { + var $p = $(this).find(".watchtogether-debug-panel"); + $p.html(buildDebugHtml()).css("display", "block"); + if (debugRefreshTimer) clearInterval(debugRefreshTimer); + debugRefreshTimer = setInterval(function () { + if ($p.is(":visible")) $p.html(buildDebugHtml()); + else { + clearInterval(debugRefreshTimer); + debugRefreshTimer = null; + } + }, 500); + }); + badge.on("mouseleave", function () { + $(this).find(".watchtogether-debug-panel").css("display", "none"); + if (debugRefreshTimer) { + clearInterval(debugRefreshTimer); + debugRefreshTimer = null; + } + }); + nameContainer.after(badge); + } + + var $badge = $(".watchtogether-room-badge"); + if (!$badge.length) return; + + var ok = room && room.alive(); + var dotColor = ok ? "#00e676" : "#ff5252"; + var dot = + ''; + + $badge + .find(".watchtogether-room-badge-text") + .html( + dot + + T.badge_room + + ': ' + + safe(currentRoomName || currentRoomId) + + " " + + '[' + + safe(currentRoomId) + + "] | " + + T.badge_viewers + + ": " + + memberCount() + + "", + ); + } + + var passwordParamItem = null; + function updatePasswordVisibility() { + if (passwordParamItem) + passwordParamItem.toggleClass("hide", !isUsePassword()); + } + + var displayNameParamItem = null; + function syncDisplayNameField() { + var name = getDisplayName(); + if (displayNameParamItem) + displayNameParamItem.find(".settings-param__value").text(name); + } + + function registerSettings() { + if (!Lampa.SettingsApi) return; + + Lampa.SettingsApi.addComponent({ + component: "lparty", + name: T.settings_title, + icon: '', + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { name: "watchtogether_meta", type: "static" }, + field: { + name: META.name + " v" + META.version, + description: "Authors: " + META.author, + }, + onRender: function (item) { + item.on("hover:enter", function () {}); + }, + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { name: "watchtogether_open_rooms", type: "button" }, + field: { + name: T.settings_open_rooms, + description: T.settings_open_rooms_descr, + }, + onChange: function () { + openRoomBrowser(); + }, + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { + name: "watchtogether_show_head", + type: "trigger", + default: true, + }, + field: { name: T.param_show_head, description: T.param_show_head_descr }, + onChange: function () { + syncHeadIcon(); + }, + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { + name: "watchtogether_display_name", + type: "input", + values: "", + default: "", + placeholder: getDisplayName(), + }, + field: { name: T.param_name, description: T.param_name_descr }, + onRender: function (item) { + displayNameParamItem = item; + syncDisplayNameField(); + }, + onChange: function () { + syncDisplayNameField(); + }, + }); + + if (Lampa.Platform.is("android")) { + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { + name: "watchtogether_player", + type: "select", + values: { + inner: Lampa.Lang.translate("settings_param_player_inner"), + android: "Android", + }, + default: "inner", + }, + field: { + name: Lampa.Lang.translate("settings_player_type"), + description: Lampa.Lang.translate("settings_player_type_descr"), + }, + onChange: function (value) { + Lampa.Storage.set("watchtogether_player", value); + playerLaunch = value; + }, + }); + } + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { name: "watchtogether_publish", type: "trigger", default: true }, + field: { name: T.param_publish, description: T.param_publish_descr }, + onChange: function () { + if (inRoom && iAmHost()) { + if (isPublish()) startLobbyAgent(); + else stopLobbyAgent(); + } + }, + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { + name: "watchtogether_use_password", + type: "trigger", + default: false, + }, + field: { name: T.param_use_pwd, description: T.param_use_pwd_descr }, + onChange: function () { + updatePasswordVisibility(); + }, + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { + name: "watchtogether_default_password", + type: "input", + values: "", + default: "", + placeholder: "", + }, + field: { name: T.param_pwd, description: T.param_pwd_descr }, + onRender: function (item) { + passwordParamItem = item; + updatePasswordVisibility(); + }, + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { + name: "watchtogether_relay", + type: "input", + values: "", + default: DEFAULT_RELAY, + placeholder: DEFAULT_RELAY, + }, + field: { name: T.param_relay, description: T.param_relay_descr }, + }); + + Lampa.SettingsApi.addParam({ + component: "lparty", + param: { + name: "watchtogether_invite", + type: "input", + values: "", + default: DEFAULT_INVITE, + placeholder: DEFAULT_INVITE, + }, + field: { name: T.param_invite, description: T.param_invite_descr }, + }); + } + + var headButton = null; + + function addHeadIcon() { + if (window.WatchTogether_head_added) return; + if (!Lampa.Head || typeof Lampa.Head.addIcon !== "function") return; + window.WatchTogether_head_added = true; + + var svg = + ''; + headButton = Lampa.Head.addIcon(svg, openRoomBrowser); + if (headButton && headButton.attr) headButton.attr("title", T.menu_title); + } + + function syncHeadIcon() { + var show = isShowHead(); + if (show) addHeadIcon(); + if (headButton && headButton.toggleClass) + headButton.toggleClass("hide", !show); + } + + if (window.appready) syncHeadIcon(); + else + Lampa.Listener.follow("app", function (e) { + if (e.type === "ready") syncHeadIcon(); + }); + + Lampa.Listener.follow("full", function (e) { + if (e.type !== "complite") return; + + var cardData = + (e.data && e.data.movie) || + (e.object && e.object.activity && e.object.activity.object + ? e.object.activity.object.item + : null) || + (e.object && e.object.item); + if (!cardData) return; + + lastViewedCard = cardData; + }); + + function onPlayerStart(e) { + if (!e || !e.url) return; + lastStreamUrl = e.url; + lastStreamTitle = + (e.movie && (e.movie.title || e.movie.name)) || e.title || ""; + + roomAliveAt = Date.now(); + + var switching = isEpisodeSwitching(); + clearEpisodeSwitch(); + + if (switching && inRoom) { + if (iAmHost()) { + currentRoomMeta.url = e.url; + currentRoomMeta.title = lastStreamTitle; + roomSend({ t: "url", url: e.url, ti: lastStreamTitle }); + } + return; + } + + if (pendingShareCard) { + var card = pendingShareCard; + var url = e.url; + var fresh = Date.now() - pendingShareAt < PENDING_SHARE_MS; + + pendingShareCard = null; + pendingShareAt = 0; + + if (fresh) { + uiPrevController = "player"; + + setTimeout(function () { + console.log("[WatchTogether]", "auto create room from card menu"); + autoCreateRoomFromPending(card, url); + }, SHARE_START_DELAY_MS); + } + } + } + + if (typeof Lampa.PlayerVideo !== "undefined" && Lampa.PlayerVideo.listener) { + Lampa.PlayerVideo.listener.follow("start", onPlayerStart); + Lampa.PlayerVideo.listener.follow("rewind", onUserRewind); + } + + if (typeof Lampa.Player !== "undefined" && Lampa.Player.listener) { + Lampa.Player.listener.follow("start", onPlayerStart); + Lampa.Player.listener.follow("destroy", function () { + clearPendingSync(); + + var vid = getVideo(); + if (vid) { + vid._lp_hooked = false; + clearRateAdjust(vid); + } + + if (!inRoom && !joining) return; + + if (isEpisodeSwitching()) return; + + leaveRoom(true); + }); + } + + if ( + typeof Lampa.PlayerPlaylist !== "undefined" && + Lampa.PlayerPlaylist.listener && + Lampa.PlayerPlaylist.listener.follow + ) { + Lampa.PlayerPlaylist.listener.follow("select", function () { + if (inRoom && iAmHost()) markEpisodeSwitch(); + }); + } + + window.addEventListener("beforeunload", function () { + if (inRoom) leaveRoom(true); + }); + + var ICON_COPY = + ''; + var ICON_SHARE = + ''; + + function canShare() { + try { + return ( + typeof navigator !== "undefined" && + typeof navigator.share === "function" + ); + } catch (err) { + return false; + } + } + + function legacyCopy(text) { + try { + var area = document.createElement("textarea"); + + area.value = text; + area.setAttribute("readonly", ""); + area.style.position = "fixed"; + area.style.top = "-1000px"; + area.style.opacity = "0"; + + document.body.appendChild(area); + area.select(); + if (area.setSelectionRange) area.setSelectionRange(0, text.length); + + var ok = document.execCommand("copy"); + + document.body.removeChild(area); + + return !!ok; + } catch (err) { + return false; + } + } + + function copyToClipboard(text, done) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then( + function () { + done(true); + }, + function () { + done(legacyCopy(text)); + }, + ); + return; + } + } catch (err) {} + + done(legacyCopy(text)); + } + + function ensureQrStyle() { + if (document.getElementById("watchtogether-qr-style")) return; + + var style = document.createElement("style"); + + style.id = "watchtogether-qr-style"; + style.textContent = + ".watchtogether-qr-actions{display:flex;justify-content:center;gap:0.8em;margin-top:0.9em;}" + + ".watchtogether-qr-btn{display:inline-flex;align-items:center;justify-content:center;width:2.8em;height:2.8em;" + + "border-radius:50%;background:rgba(255,255,255,0.14);color:#fff;cursor:pointer;" + + "transition:background 0.2s,color 0.2s,transform 0.2s;padding:0.7em;}" + + ".watchtogether-qr-btn.focus,.watchtogether-qr-btn:hover{background:#fff;color:#000;transform:scale(1.08);}"; + + document.head.appendChild(style); + } + + function showRoomQr() { + var link = inviteLink(); + var qrUrl = + "https://api.qrserver.com/v1/create-qr-code/?size=600x600&data=" + + encodeURIComponent(link); + + var isMobile = + Lampa.Platform && + Lampa.Platform.screen && + Lampa.Platform.screen("mobile"); + uiPrevController = isMobile ? "player" : "player_panel"; + + ensureQrStyle(); + + var actions = + '
' + + '
' + + ICON_COPY + + "
" + + (canShare() + ? '
' + + ICON_SHARE + + "
" + : "") + + "
"; + + var body = $( + '
' + + '
' + + T.qr_title + + " - " + + safe(currentRoomName || currentRoomId || "") + + "
" + + '' + + '
' + + T.qr_hint + + "
" + + '
' + + safe(link) + + "
" + + actions + + "
", + ); + + Lampa.Modal.open({ + title: "", + size: "medium", + html: body, + select: body.find('[data-watchtogether-action="copy"]')[0], + onSelect: function (item) { + var target = $(item); + var action = + target.attr("data-watchtogether-action") || + target + .closest("[data-watchtogether-action]") + .attr("data-watchtogether-action"); + + if (action === "copy") { + copyToClipboard(link, function (ok) { + lplog("invite link copy", ok ? "ok" : "failed"); + Lampa.Noty.show(ok ? T.qr_copied : T.qr_copy_fail); + }); + return; + } + + if (action === "share") { + try { + var shared = navigator.share({ + title: T.qr_title, + text: currentRoomName || currentRoomId || T.qr_title, + url: link, + }); + + if (shared && shared["catch"]) + shared["catch"](function (err) { + if (err && err.name === "AbortError") return; + lplog("share failed", err && err.name); + }); + } catch (err) { + lplog("share threw", err && err.message); + } + } + }, + onBack: function () { + Lampa.Modal.close(); + restoreController(); + }, + }); + } + + function createRoomFromPlayer() { + dropStaleRoom(); + + if (inRoom) { + Lampa.Noty.show( + T.already_in_room(currentRoomName || currentRoomId || ""), + ); + return; + } + if (!lastStreamUrl) { + Lampa.Noty.show(T.need_url); + return; + } + + var isMobile = + Lampa.Platform && + Lampa.Platform.screen && + Lampa.Platform.screen("mobile"); + uiPrevController = isMobile ? "player" : "player_panel"; + restoreController(); + + autoCreateRoomFromPending(lastViewedCard || {}, lastStreamUrl); + } + + if ( + typeof Lampa.Select !== "undefined" && + Lampa.Select.listener && + Lampa.Select.listener.follow + ) { + Lampa.Select.listener.follow("preshow", function (e) { + if (!e || !e.active || !Array.isArray(e.active.items)) return; + + var items = e.active.items; + var hasPlayer = false, + hasFileMenu = false, + isPlayerSettings = false, + alreadyInjected = false; + + for (var i = 0; i < items.length; i++) { + var it = items[i]; + if (!it) continue; + if (it.player) hasPlayer = true; + if (it.mark || it.timeclear || it.clearmark) hasFileMenu = true; + if ( + it.method === "size" || + it.method === "speed" || + it.method === "subs" || + it.method === "share" || + it.method === "segments" + ) + isPlayerSettings = true; + if (it.watchtogether_inject || it.watchtogether_inject_player) + alreadyInjected = true; + } + if (alreadyInjected) return; + + if (hasPlayer && hasFileMenu) { + items.push({ + title: T.full_card_btn, + player: "inner", + watchtogether_inject: true, + }); + + var originalOnSelect = e.active.onSelect; + e.active.onSelect = function (a) { + if (a && a.watchtogether_inject) { + pendingShareCard = lastViewedCard || {}; + pendingShareAt = Date.now(); + Lampa.Noty.show(T.pending_share); + } + if (originalOnSelect) originalOnSelect(a); + }; + return; + } + + if (isPlayerSettings) { + items.push({ + title: T.full_card_btn, + subtitle: inRoom ? T.player_qr_descr : T.player_create_descr, + method: "watchtogether_create", + watchtogether_inject_player: true, + }); + + var originalOnSelectP = e.active.onSelect; + e.active.onSelect = function (a) { + if (a && a.watchtogether_inject_player) { + if (inRoom) showRoomQr(); + else createRoomFromPlayer(); + return; + } + if (originalOnSelectP) originalOnSelectP(a); + }; + } + }); + } + + window.WatchTogether = { + version: META.version, + log: function () { + return logRing.join("\n"); + }, + dump: function () { + console.log(logRing.join("\n")); + return logRing.length; + }, + state: function () { + var vid = getVideo(); + return { + room: currentRoomId, + name: currentRoomName, + host: iAmHost(), + owner: currentRoomOwner, + inRoom: inRoom, + joining: joining, + members: memberCount(), + relay: room && room.ws ? room.ws.readyState : null, + lobby: !!(lobbyHost && lobbyHost.alive()), + hold: holdActive, + holdPosition: holdPosition, + holdWaiting: Object.keys(holdWaiting), + initialSyncLock: initialSyncLock, + clockOffset: Math.round(serverTimeOffset), + echoPending: Object.keys(echoPending).length, + video: vid + ? { + position: vid.currentTime, + paused: vid.paused, + rate: vid.playbackRate, + readyState: vid.readyState, + buffering: !!vid._lp_buffering, + } + : null, + }; + }, + leave: function () { + leaveRoom(true); + return "left"; + }, + }; + + registerSettings(); + console.log( + "[WatchTogether]", + lplog( + "started, relay: " + + getRelay() + + ", lang: " + + _rawLang + + ", name: " + + getDisplayName(), + ), + ); +})(); diff --git a/mods/WatchTogether/web/i18n.js b/mods/WatchTogether/web/i18n.js new file mode 100644 index 0000000..7d69595 --- /dev/null +++ b/mods/WatchTogether/web/i18n.js @@ -0,0 +1,324 @@ +/** + * Three languages, no libraries. Auto-detection picks between Ukrainian and English + * only: Russian is in the list but is never selected without an explicit choice. + * + * A dictionary value is either a string or an object of plural forms + * (one/few/many/other) resolved through Intl.PluralRules. + */ + +export const LANGS = [ + { code: "uk", name: "Українська" }, + { code: "en", name: "English" }, + { code: "ru", name: "Русский" }, +]; + +const DICT = { + uk: { + tagline: "Спільний перегляд із Lampa", + lang_label: "Мова", + + join_heading: "Увійти за кодом", + code_ph: "Код кімнати", + pwd_ph: "Пароль", + join_btn: "Увійти", + + rooms_heading: "Відкриті кімнати", + refresh_btn: "Оновити", + searching: "Шукаємо…", + empty_title: "Поки жодної кімнати", + empty_hint: + "Кімната з’явиться тут, щойно хтось у Lampa створить її з увімкненою публікацією. Якщо код уже є — введіть його вище.", + + create_heading: "Створити кімнату", + url_ph: "Посилання на потік (.m3u8 або .mp4)", + title_ph: "Назва", + create_btn: "Створити", + url_hint: "Потрібне https-посилання, і сервер має дозволяти CORS.", + cancel_btn: "Скасувати", + done_btn: "Готово", + + settings_heading: "Налаштування", + name_ph: "Ваше ім’я", + regenerate_name: "Згенерувати інше ім’я", + relay_label: "Реле", + publish_label: "Показувати мою кімнату у списку", + + leave_btn: "Вийти", + host_badge: "ви хост", + switch_ph: "Нове посилання на потік", + switch_btn: "Перемкнути всім", + status_connecting: "З’єднання…", + status_online: "На зв’язку", + status_offline: "Зв’язок втрачено", + viewers: { + one: "{n} глядач", + few: "{n} глядачі", + many: "{n} глядачів", + other: "{n} глядача", + }, + + room_created: "Кімнату створено · код {id}", + join_ok: "Ви в кімнаті {name}", + joined: "{name} приєднався", + left_room: "{name} вийшов", + host_changed: "Новий хост: {name}", + you_are_host: "Тепер ви хост", + notice_hold: "Пауза — чекаємо, поки всі завантажать буфер", + notice_go: "Усі готові — продовжуємо", + act_resumed: "{name} продовжив", + act_paused: "{name} поставив на паузу", + act_seeked: "{name} перемотав", + someone: "учасник", + + connecting: "Підключення…", + password_prompt: "Пароль кімнати {name}", + need_code: "Введіть код кімнати", + need_url: "Потрібне посилання на потік", + need_pwd_public: + "Кімната у списку потребує пароля — задайте його або вимкніть публікацію", + already_in_room: "Ви вже в кімнаті {name}", + create_failed: "Не вдалося створити кімнату", + join_failed: "Кімнату не знайдено або пароль невірний", + kicked: "Ви підключилися з іншого місця", + no_stream: "У кімнаті немає потоку", + relay_error: "Реле: {text}", + autoplay_blocked: "Браузер заблокував автозапуск — натисніть play", + mixed_content: + "Потік віддається по http, а сторінка по https — браузер це блокує", + cors_error: + "Потік не завантажується: схоже, сервер не віддає Access-Control-Allow-Origin", + playback_error: "Помилка відтворення: {details}", + media_error: + "Браузер не зміг відкрити потік (формат, CORS або mixed content)", + }, + + en: { + tagline: "Watch together with Lampa", + lang_label: "Language", + + join_heading: "Join with a code", + code_ph: "Room code", + pwd_ph: "Password", + join_btn: "Join", + + rooms_heading: "Open rooms", + refresh_btn: "Refresh", + searching: "Searching…", + empty_title: "No rooms right now", + empty_hint: + "A room appears here as soon as someone in Lampa creates one with publishing enabled. If you already have a code, enter it above.", + + create_heading: "Create a room", + url_ph: "Stream link (.m3u8 or .mp4)", + title_ph: "Title", + create_btn: "Create", + url_hint: "Needs an https link from a server that allows CORS.", + cancel_btn: "Cancel", + done_btn: "Done", + + settings_heading: "Settings", + name_ph: "Your name", + regenerate_name: "Generate another name", + relay_label: "Relay", + publish_label: "List my room publicly", + + leave_btn: "Leave", + host_badge: "you host", + switch_ph: "New stream link", + switch_btn: "Switch for everyone", + status_connecting: "Connecting…", + status_online: "Connected", + status_offline: "Connection lost", + viewers: { one: "{n} viewer", other: "{n} viewers" }, + + room_created: "Room created · code {id}", + join_ok: "You are in {name}", + joined: "{name} joined", + left_room: "{name} left", + host_changed: "New host: {name}", + you_are_host: "You are the host now", + notice_hold: "Paused — waiting for everyone to buffer", + notice_go: "Everyone is ready — resuming", + act_resumed: "{name} resumed", + act_paused: "{name} paused", + act_seeked: "{name} skipped", + someone: "someone", + + connecting: "Connecting…", + password_prompt: "Password for {name}", + need_code: "Enter a room code", + need_url: "A stream link is required", + need_pwd_public: + "A publicly listed room needs a password — set one or turn off publishing", + already_in_room: "You are already in {name}", + create_failed: "Could not create the room", + join_failed: "Room not found, or the password is wrong", + kicked: "You connected from somewhere else", + no_stream: "The room has no stream", + relay_error: "Relay: {text}", + autoplay_blocked: "The browser blocked autoplay — press play", + mixed_content: + "The stream is served over http while this page is https — the browser blocks that", + cors_error: + "The stream will not load: the server likely omits Access-Control-Allow-Origin", + playback_error: "Playback error: {details}", + media_error: + "The browser could not open the stream (format, CORS or mixed content)", + }, + + ru: { + tagline: "Совместный просмотр с Lampa", + lang_label: "Язык", + + join_heading: "Войти по коду", + code_ph: "Код комнаты", + pwd_ph: "Пароль", + join_btn: "Войти", + + rooms_heading: "Открытые комнаты", + refresh_btn: "Обновить", + searching: "Ищем…", + empty_title: "Пока ни одной комнаты", + empty_hint: + "Комната появится здесь, как только кто-то в Lampa создаст её с включённой публикацией. Если код уже есть — введите его выше.", + + create_heading: "Создать комнату", + url_ph: "Ссылка на поток (.m3u8 или .mp4)", + title_ph: "Название", + create_btn: "Создать", + url_hint: "Нужна https-ссылка, и сервер должен разрешать CORS.", + cancel_btn: "Отмена", + done_btn: "Готово", + + settings_heading: "Настройки", + name_ph: "Ваше имя", + regenerate_name: "Сгенерировать другое имя", + relay_label: "Реле", + publish_label: "Показывать мою комнату в списке", + + leave_btn: "Выйти", + host_badge: "вы хост", + switch_ph: "Новая ссылка на поток", + switch_btn: "Переключить всем", + status_connecting: "Соединение…", + status_online: "На связи", + status_offline: "Связь потеряна", + viewers: { + one: "{n} зритель", + few: "{n} зрителя", + many: "{n} зрителей", + other: "{n} зрителя", + }, + + room_created: "Комната создана · код {id}", + join_ok: "Вы в комнате {name}", + joined: "{name} присоединился", + left_room: "{name} вышел", + host_changed: "Новый хост: {name}", + you_are_host: "Теперь вы хост", + notice_hold: "Пауза — ждём, пока все загрузят буфер", + notice_go: "Все готовы — продолжаем", + act_resumed: "{name} продолжил", + act_paused: "{name} поставил на паузу", + act_seeked: "{name} перемотал", + someone: "участник", + + connecting: "Подключение…", + password_prompt: "Пароль комнаты {name}", + need_code: "Введите код комнаты", + need_url: "Нужна ссылка на поток", + need_pwd_public: + "Комната в списке требует пароль — задайте его или отключите публикацию", + already_in_room: "Вы уже в комнате {name}", + create_failed: "Не удалось создать комнату", + join_failed: "Комната не найдена или пароль неверный", + kicked: "Вы подключились из другого места", + no_stream: "В комнате нет потока", + relay_error: "Реле: {text}", + autoplay_blocked: "Браузер заблокировал автозапуск — нажмите play", + mixed_content: + "Поток отдаётся по http, а страница по https — браузер это блокирует", + cors_error: + "Поток не загружается: похоже, сервер не отдаёт Access-Control-Allow-Origin", + playback_error: "Ошибка воспроизведения: {details}", + media_error: + "Браузер не смог открыть поток (формат, CORS или mixed content)", + }, +}; + +export const DICTS = DICT; // exposed so tests can check the dictionaries are complete + +const STORE_KEY = "lparty_lang"; +const listeners = new Set(); +let current = "uk"; + +/** Russian is deliberately not reachable here: it is a manual choice only. */ +export function detectLang() { + for (const tag of navigator.languages || [navigator.language || ""]) { + const code = String(tag).toLowerCase().split("-")[0]; + if (code === "uk") return "uk"; + if (code === "en") return "en"; + } + return "uk"; +} + +export function getLang() { + return current; +} + +export function setLang(code) { + if (!DICT[code]) return; + current = code; + try { + localStorage.setItem(STORE_KEY, code); + } catch (err) {} + document.documentElement.lang = code; + listeners.forEach((fn) => fn(code)); +} + +export function initLang() { + let saved = null; + try { + saved = localStorage.getItem(STORE_KEY); + } catch (err) {} + current = DICT[saved] ? saved : detectLang(); + document.documentElement.lang = current; + return current; +} + +export function onLangChange(fn) { + listeners.add(fn); +} + +export function t(key, params) { + let value = DICT[current][key]; + if (value === undefined) return key; // visible immediately; silence would be worse + + if (typeof value === "object") { + const n = Number(params && params.n); + const form = new Intl.PluralRules(current).select(n); + value = value[form] ?? value.other; + } + + return params + ? value.replace(/\{(\w+)\}/g, (m, name) => + params[name] === undefined ? m : params[name], + ) + : value; +} + +/** + * Repaints static nodes. data-i18n sets textContent, data-i18n-ph sets the + * placeholder, data-i18n-label sets aria-label. + */ +export function applyTranslations(root = document) { + root.querySelectorAll("[data-i18n]").forEach((el) => { + el.textContent = t(el.dataset.i18n); + }); + root.querySelectorAll("[data-i18n-ph]").forEach((el) => { + el.placeholder = t(el.dataset.i18nPh); + }); + root.querySelectorAll("[data-i18n-label]").forEach((el) => { + el.setAttribute("aria-label", t(el.dataset.i18nLabel)); + }); +} diff --git a/mods/WatchTogether/web/index.html b/mods/WatchTogether/web/index.html new file mode 100644 index 0000000..7e481ea --- /dev/null +++ b/mods/WatchTogether/web/index.html @@ -0,0 +1,1456 @@ + + + + + + + WatchTogether + + + + + + + +
+ + +
+
+
+ WatchTogether +
+
+ +
+ +
+
+

+
+ + + +
+ +
+

+ + +
+
+ +
+ +
+

+
+ +

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

+ + + +
+
+
+ +
+
+ + + + + + + + +
+ +
+ +
+
+ +
+ + + + + + diff --git a/mods/WatchTogether/web/lparty-web.js b/mods/WatchTogether/web/lparty-web.js new file mode 100644 index 0000000..32e0dc1 --- /dev/null +++ b/mods/WatchTogether/web/lparty-web.js @@ -0,0 +1,1898 @@ +/** + * Port of the lparty.js protocol (Lampa watch-party) to a plain web page. + * Wire-compatible: same channels, same messages, same timings. + * Original: https://nrsua.github.io/lampa-nrs/lparty.js + * + * The transport is the itty.ws relay, which is stateless: it broadcasts inside a + * channel and adds a server timestamp plus a member list. All room logic lives + * here, so there is no server side to write. + */ + +export const DEFAULT_RELAY = "{relay}"; +export const LOBBY_CHANNEL = "lparty-lobby-v1"; +export const ROOM_PREFIX = "lparty-r-"; + +/** + * Copied verbatim from the original. These are calibrated against their relay and + * their players: change them on one side only and two clients pull each other in + * different directions, which is worse than no correction at all. + */ +export const TUNING = { + toleranceS: 0.3, + // Correction starts above correctOnS and only stops below toleranceS. Without that + // hysteresis the rate flapped around a single threshold on every heartbeat. + correctOnS: 0.7, + hardSeekS: 1.5, + // Native Apple HLS seeks coarsely and ignores playbackRate, so it gets a looser bound. + hardSeekNativeS: 3.0, + rateGain: 0.1, + maxRateOffset: 0.1, + rateResetMs: 4000, + heartbeatMs: 2000, + pingIntervalMs: 8000, + echoTimeoutMs: 30000, + reconnectMs: 4000, + joinTimeoutMs: 6000, + // The original 1500 was too tight: replies land ~1.2 s after the query and missed + // the window. Purely local, so it does not affect wire compatibility. + lobbyCollectMs: 2500, + userActionMs: 2000, + systemSyncMs: 500, + initialLockMs: 3000, + expectPlayMs: 500, + + // Buffering barrier, added in plugin 1.3.0. The host pauses everyone at one + // position and waits for a `ready` from each participant it is holding for. + holdMaxMs: 15000, + holdBufferS: 6, + holdMinBufferS: 2, + holdStallMs: 3000, + holdNudgeMs: 4000, + + rewindGraceMs: 2500, + hardSeekCooldownMs: 3000, + reconnectHelloMs: 60000, + + // User seeks + seekGuardMs: 4000, // how long a position we set ourselves stays "ours" + seekBroadcastMinMs: 2000, // a scrub is throttled into one trailing broadcast, never dropped + seekMinJumpS: 1.0, // below this the position barely moved; nothing to announce + pendingActMaxMs: 15000, // how long a peer's seek is retried before giving up + + // End of media. Purely local, like the two above: they change what we say and what we obey, + // never the wire, so a Lampa peer needs no matching calibration. + endSlackS: 1.0, // a reported position this near our own duration is a file running out + reportGapMs: 1500, // how far apart two reports must be to say whether their sender moves +}; + +const NO_RANGES = -2; +const OUT_OF_RANGE = -1; + +// ---------------------------------------------------------------- pure functions + +/** + * The original hashes UTF-8 bytes (unescape(encodeURIComponent(s))), so the built-in + * crypto.subtle yields a byte-identical digest — verified against their own code. + * Needs a secure context; GitHub Pages is always https, so that costs nothing. + */ +export async function sha256hex(str) { + const buf = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(str), + ); + return [...new Uint8Array(buf)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export async function roomChannel(roomId, password) { + const hex = await sha256hex( + (roomId || "").toUpperCase() + "|" + (password || ""), + ); + return ROOM_PREFIX + hex.substr(0, 24); +} + +export function newRoomId() { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no lookalike I/O/0/1 + let id = ""; + for (let i = 0; i < 6; i++) + id += chars.charAt(Math.floor(Math.random() * chars.length)); + return id; +} + +/** Where the room should be right now, given it plays from basePosition since atServerTime. */ +export function expectedPositionNow(state, basePosition, atServerTime, nowMs) { + if (state !== "playing" || !atServerTime || atServerTime <= 0) + return basePosition; + const elapsedSec = (nowMs - atServerTime) / 1000; + if (elapsedSec < 0 || elapsedSec > 3600) return basePosition; // garbage timestamp + return basePosition + elapsedSec; +} + +/** + * diff = our position minus the expected one. Positive diff means we ran ahead. + * + * @param opts.correcting already nudging the rate — keep going until inside toleranceS + * @param opts.hardSeekS threshold for giving up on rate and jumping + * @param opts.canAdjustRate false for players that ignore playbackRate (native Apple HLS) + */ +export function syncDecision(diff, opts = {}, t = TUNING) { + const { + correcting = false, + hardSeekS = t.hardSeekS, + canAdjustRate = true, + } = opts; + const abs = Math.abs(diff); + + if (abs > hardSeekS) return { action: "seek", rate: 1 }; + + // Enter correction only on a real gap, leave it only when properly closed. + const enterAt = correcting ? t.toleranceS : t.correctOnS; + if (canAdjustRate && abs > enterAt) { + const offset = Math.max( + -t.maxRateOffset, + Math.min(t.maxRateOffset, diff * t.rateGain), + ); + return { action: "rate", rate: 1 - offset }; + } + + return { action: "reset", rate: 1 }; +} + +/** + * Whether a reported position is that sender's file running out rather than a place in it. + * Lampa announces the end of an episode as a pause carrying the duration to the millisecond, + * and obeyed literally that skips whatever is left here and parks on the last frame. + * + * Unknown or infinite duration — a player still loading, a live stream — answers no: there is + * nothing to compare against, and a live stream has no end to be at. + */ +export function atMediaEnd(position, duration, t = TUNING) { + if (!Number.isFinite(duration) || duration <= 0) return false; + return position >= duration - t.endSlackS; +} + +/** + * Whether the sender of two reports is standing still while calling itself playing — a peer + * refilling its buffer. Lampa's heartbeat repeats position and state without checking that + * playback advanced, and each repeat read as the room having jumped backwards. + * + * @returns true / false, or null when these two reports cannot say (a different sender, a + * sender that is paused, less than one heartbeat apart, or a jump backwards, which is + * a seek and is heard as one) + */ +export function reportStalled(prev, report, t = TUNING) { + if (!prev || prev.pid !== report.pid || !report.playing) return null; + const gap = report.at - prev.at; + if (gap < t.reportGapMs) return null; + const moved = report.position - prev.position; + if (moved < 0) return null; + // Against the sender's own rate: at half speed a player covers half the gap, and flat + // arithmetic would call that a stall — one peer picking 0.5x would stop everybody else. + const due = (gap / 1000) * Math.max(0.05, report.speed || 1); + return moved * 2 < due; +} + +/** Take the sample with the lowest RTT — it is the least distorted one. */ +export function pickOffset(samples) { + if (!samples.length) return 0; + let best = samples[0]; + for (const s of samples) if (s.rtt < best.rtt) best = s; + return best.offset; +} + +// ---------------------------------------------------------------- relay socket + +class Sock { + constructor({ + relay, + channel, + alias = "", + echo = false, + reconnect = false, + onEvent = () => {}, + }) { + this.relay = relay; + this.channel = channel; + this.alias = alias; + this.echo = echo; + this.reconnect = reconnect; + this.onEvent = onEvent; + this.ws = null; + this.uid = null; + this.members = []; + this.closed = false; + this.retryTimer = null; + this.onReady = null; + } + + url() { + let q = "announce=true&list=true"; + if (this.echo) q += "&echo=true"; + if (this.alias) q += "&as=" + encodeURIComponent(this.alias); + return this.relay + encodeURIComponent(this.channel) + "?" + q; + } + + open() { + if (this.closed) return; + if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) + return; + + try { + this.ws = new WebSocket(this.url()); + } catch (err) { + this.scheduleRetry(); + return; + } + + this.ws.onopen = () => this.onEvent({ kind: "open" }); + + this.ws.onmessage = (e) => { + let d; + try { + d = JSON.parse(e.data); + } catch (err) { + return; + } + if (!d) return; + + if (d.type === "join") { + if (d.self) { + this.uid = d.uid; + this.members = []; + for (const u of d.users || []) { + if (!this.findMember(u.uid)) + this.members.push({ uid: u.uid, alias: u.alias || "" }); + } + // The relay does not always list us among the users; host election counts + // members, so we have to be in our own list. + if (!this.findMember(this.uid)) + this.members.push({ uid: this.uid, alias: this.alias }); + this.onEvent({ + kind: "ready", + date: d.date, + total: d.total, + members: this.members, + }); + } else { + if (!this.findMember(d.uid)) + this.members.push({ uid: d.uid, alias: d.alias || "" }); + this.onEvent({ + kind: "join", + date: d.date, + uid: d.uid, + alias: d.alias || "", + total: d.total, + }); + } + return; + } + + if (d.type === "leave") { + this.members = this.members.filter((m) => m.uid !== d.uid); + this.onEvent({ + kind: "leave", + date: d.date, + uid: d.uid, + alias: d.alias || "", + total: d.total, + }); + return; + } + + if (d.type === "error") { + this.onEvent({ kind: "error", date: d.date, text: d.message }); + return; + } + + if (d.message && typeof d.message === "object") { + this.onEvent({ + kind: "msg", + date: d.date, + uid: d.uid, + alias: d.alias || "", + msg: d.message, + mine: d.uid === this.uid, + }); + } + }; + + this.ws.onclose = () => { + this.uid = null; + this.members = []; + this.onEvent({ kind: "close" }); + this.scheduleRetry(); + }; + + this.ws.onerror = () => {}; + } + + scheduleRetry() { + if (!this.reconnect || this.closed) return; + clearTimeout(this.retryTimer); + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.open(); + }, TUNING.reconnectMs); + } + + findMember(uid) { + return this.members.find((m) => m.uid === uid) || null; + } + + alive() { + return !!this.ws && this.ws.readyState === 1; + } + + send(obj) { + if (!this.alive()) return false; + try { + this.ws.send(JSON.stringify(obj)); + return true; + } catch (err) { + return false; + } + } + + close() { + this.closed = true; + clearTimeout(this.retryTimer); + this.retryTimer = null; + if (this.ws) { + try { + this.ws.close(); + } catch (err) {} + this.ws = null; + } + } +} + +// ---------------------------------------------------------------- room + +const emptyMeta = () => ({ + title: "", + poster: "", + url: "", + tmdb_id: 0, + source: "", + type: "", +}); + +export class Party { + /** + * @param video the