TelegramAuth + TelegramAuthBot + WatchTogether; README/AGENT
- mods/TelegramAuth, mods/TelegramAuthBot, mods/WatchTogether: копии из образа с enable: true (правило 1), секции в config/init.conf - TelegramAuth в API-режиме (enable: false -> accsdb/гейт не поднимается), TelegramAuthBot: бот @GatusMonBot, long polling - WatchTogether: customPlugins watchtogether.js, relay /wt/c/ - README: секция про SQLite/volume (первый запуск), таблица модулей, состояние Tg-notify и TelegramAuth, раздел WatchTogether - AGENT: правило 10 дополнено секретами init.conf, §12 переписан, добавлен §13 WatchTogether, список mods/ в §1 - в git bot_token и mutations_api_secret оставлены пустыми (правило 10), реальные значения только в рабочем файле на сервере
This commit is contained in:
@@ -28,6 +28,8 @@ docker cp lampac:/lampac/module/<Имя> "mods/<Имя>"
|
|||||||
|
|
||||||
См. также официальные шаги: [Включить модуль](https://docs.lampac.dev/modules/overview), [Пользовательские модули](https://docs.lampac.dev/maintenance/custom-modules).
|
См. также официальные шаги: [Включить модуль](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 независимых фильтра)
|
### 2. Механика загрузки модуля (4 независимых фильтра)
|
||||||
|
|
||||||
Модуль грузится, только если выполнены **все**:
|
Модуль грузится, только если выполнены **все**:
|
||||||
@@ -112,11 +114,14 @@ docker exec lampac sh -c '...' # осмотр состояния в конте
|
|||||||
|
|
||||||
### 10. Безопасность (не публиковать)
|
### 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-бот)
|
### 11. Tg-notify.bot (Telegram-бот)
|
||||||
|
|
||||||
- Механика: long polling по `TelegramBot.bot_token`; если токен пуст — polling не стартует (в логе `[TelegramBot] Установите bot_token…`, сервер работает). После правки секции: `docker compose restart lampac`.
|
- Механика: 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.
|
- Три подсистемы: трекер серий (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). Не «чинить» вручную — это живые данные бота.
|
- Данные подписок: `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); тогда уведомления приходят только о выходе серий.
|
- Диагностика: `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)
|
### 12. TelegramAuth / TelegramAuthBot (вход через Telegram)
|
||||||
|
|
||||||
- Не путать с Tg-notify.bot (уведомления): эта пара — **аутентификация** (вход в Lampa через Telegram вместо пароля), группа Community, в образе выключена (`SkipModules` + `enable: false`).
|
- Не путать с Tg-notify.bot (уведомления): эта пара — **аутентификация** (вход в Lampa через Telegram вместо пароля), группа Community. Копии в `mods/TelegramAuth/` и `mods/TelegramAuthBot/`, манифесты `enable: true`, из `SkipModules` убраны (у нас `SkipModules: []`).
|
||||||
- Гейт (`LampaWeb.telegramAuthGate`) активен только при `accsdb.enable: true` + `telegramAuthGate.enabled: true` + непустом `botUsername`, иначе тихий откат на `deny.js`. У нас `accsdb` не включён — связка неактуальна, включать только вместе с `accsdb`.
|
- **Сейчас: API-режим.** `TelegramAuth.enable: false` → `accsdb` не поднимается, гейт неактивен, доступ не режется; работают привязки, `/tg/auth/*`, админ-API. `TelegramAuthBot` — бот **`@GatusMonBot`**, long polling идёт (`Telegram API OK`).
|
||||||
- `mutations_api_secret` одинаковый в обоих модулях, ненулевой в проде; `bot_token` — в `init.conf`, не коммитить (см. правило 10).
|
- Гейт (`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 и таймаут больше длительности просмотра.
|
||||||
|
|||||||
@@ -16,6 +16,56 @@ docker compose restart # после правки config/init.conf или
|
|||||||
|
|
||||||
По умолчанию сервер слушает порт **9118**; если порт занят — меняй `listen.port` в `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/init.conf` | Конфигурация сервера (JSON, монтируется в `/lampac/init.conf`) |
|
||||||
| `config/passwd` | Пароль root для `/adminpanel` и `/weblog` (не в git) |
|
| `config/passwd` | Пароль root для `/adminpanel` и `/weblog` (не в git) |
|
||||||
| volume `lampac_cache` | Кеш (`/lampac/cache`) — Docker named volume, не файловый том |
|
| 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`) |
|
| `dlna/` | Локальные медиа для DLNA (`/lampac/data/dlna`) |
|
||||||
| `plugins/extensions.json` | Каталог плагинов для веб-клиента (раздел «Плагины»), отдаётся через `/extensions` |
|
| `plugins/extensions.json` | Каталог плагинов для веб-клиента (раздел «Плагины»), отдаётся через `/extensions` |
|
||||||
| `mods/<Имя>/` | Пользовательские копии встроенных модулей с `enable: true` |
|
| `mods/<Имя>/` | Пользовательские копии встроенных модулей с `enable: true` |
|
||||||
@@ -53,14 +103,18 @@ docker compose restart # после правки config/init.conf или
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Tracks` | `/tracks.js`, `/ffprobe` — субтитры и дорожки | `"Tracks": { "enable": true }` |
|
| `Tracks` | `/tracks.js`, `/ffprobe` — субтитры и дорожки | `"Tracks": { "enable": true }` |
|
||||||
| `AdminPanel` | `/adminpanel` — редактор init.conf, current.conf, users.json | `"AdminPanel": { "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"` |
|
| `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 Подписки» видна.
|
- Модуль загружен, `/tg-notify.js` → 200, плагин уже в `LampaWeb.customPlugins`, вкладка «TG Подписки» видна; аккаунт привязан (`database/tgnotify/users.json`).
|
||||||
- `bot_token` **пуст** → long polling не работает (лог: `[TelegramBot] Установите bot_token…`); уведомления придут только после заполнения токена.
|
- `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).
|
- Трекинг озвучек зависит от балансиров: на текущий момент `GET /lite/mirage` → тело `disable`, `GET /lite/collaps` → `503` — до тех пор, пока они не будут доступны (или не включится `lampac_token` при защите), бот будет уведомлять только о выходе серий (TMDB/Trakt).
|
||||||
|
|
||||||
## TelegramAuth + TelegramAuthBot: вход в Lampa через Telegram
|
## 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`).
|
`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=<base64(КОД:пароль)>` |
|
||||||
|
| 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
|
## Обновление Lampac
|
||||||
|
|
||||||
@@ -234,7 +310,7 @@ docker compose up -d
|
|||||||
|
|
||||||
Перед обновлением проверить, что в `docker-compose.yaml` раскомментированы нужные тома. Данные сохраняются в примонтированных томах: `config/`, `cache/`, `database/`, `dlna/`, `mods/`.
|
Перед обновлением проверить, что в `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-модули.
|
- Не публикуй в интернет без аутентификации, WAF и ограничения сети: `/adminpanel`, `/weblog`, `/database-editor`, `Tracks`, `Transcoding`, `Catalog`, `DLNA`, proxy-модули.
|
||||||
- Нигде не раскрывай `config/passwd`, cookie `accspasswd`, заголовок `lcrqpasswd`.
|
- Нигде не раскрывай `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).
|
- Для публикации за reverse proxy: [deployment/reverse-proxy](https://docs.lampac.dev/deployment/reverse-proxy) (HTTPS в ответах, реальный IP клиента, WebSocket, SSE, streaming).
|
||||||
- `shm_size: 4096mb` — рекомендованный минимум для Playwright/Chromium.
|
- `shm_size: 4096mb` — рекомендованный минимум для Playwright/Chromium.
|
||||||
|
|||||||
+59
-1
@@ -33,7 +33,16 @@
|
|||||||
"backup": true
|
"backup": true
|
||||||
},
|
},
|
||||||
"customPlugins": [
|
"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": {
|
"chromium": {
|
||||||
@@ -56,5 +65,54 @@
|
|||||||
"history": {
|
"history": {
|
||||||
"enable": false
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<ActionResult> 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<string>("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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WafLimitRootMap>
|
||||||
|
{
|
||||||
|
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<WafLimitRootMap>();
|
||||||
|
var patterns = new HashSet<string>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LegacyDeviceRecord> 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<string, object>? @params { get; set; }
|
||||||
|
|
||||||
|
public List<string>? 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<DeviceRecord> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 без немедленной активации.
|
||||||
@@ -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<string>();
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
var paramDict = new Dictionary<string, object>(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<AccsUser> ReadListUnlocked()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var full = Path.GetFullPath(UsersFileName);
|
||||||
|
if (!File.Exists(full))
|
||||||
|
return new List<AccsUser>();
|
||||||
|
|
||||||
|
var txt = File.ReadAllText(full);
|
||||||
|
if (string.IsNullOrWhiteSpace(txt))
|
||||||
|
return new List<AccsUser>();
|
||||||
|
|
||||||
|
return JsonConvert.DeserializeObject<List<AccsUser>>(txt) ?? new List<AccsUser>();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new List<AccsUser>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void WriteListUnlocked(List<AccsUser> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DeviceRecord>())
|
||||||
|
{
|
||||||
|
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<DeviceRecord>())
|
||||||
|
{
|
||||||
|
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<DeviceRecord>())
|
||||||
|
{
|
||||||
|
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<DeviceRecord>()
|
||||||
|
};
|
||||||
|
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<TelegramUserRecord>? 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<string>()))
|
||||||
|
user.ExpiresAt = null;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var s = t.Type == JTokenType.String ? t.Value<string>() : 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<string>()?.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(s))
|
||||||
|
user.Lang = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.ContainsKey("role"))
|
||||||
|
{
|
||||||
|
var r = body["role"]?.Value<string>()?.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<string>()?.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(key))
|
||||||
|
ApplyAccsRemove(user.Accs, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body["paramsRemove"] is JArray pr)
|
||||||
|
{
|
||||||
|
user.Accs ??= new TelegramAccsProfile();
|
||||||
|
user.Accs.@params ??= new Dictionary<string, object>(StringComparer.Ordinal);
|
||||||
|
foreach (var x in pr)
|
||||||
|
{
|
||||||
|
var k = x.Value<string>();
|
||||||
|
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<long>();
|
||||||
|
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<bool>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<bool>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>()?.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<string>()?.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<string, object>(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<string>() ?? "")
|
||||||
|
: prop.Value.Type == JTokenType.Integer
|
||||||
|
? prop.Value.Value<long>()
|
||||||
|
: prop.Value.Type == JTokenType.Float
|
||||||
|
? prop.Value.Value<double>()
|
||||||
|
: prop.Value.Type == JTokenType.Boolean
|
||||||
|
? prop.Value.Value<bool>()
|
||||||
|
: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DeviceRecord>()
|
||||||
|
});
|
||||||
|
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<TelegramUserRecord> GetUsers()
|
||||||
|
{
|
||||||
|
EnsureStorage();
|
||||||
|
return JsonConvert.DeserializeObject<List<TelegramUserRecord>>(File.ReadAllText(usersPath)) ?? new List<TelegramUserRecord>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveUsers(List<TelegramUserRecord> users)
|
||||||
|
{
|
||||||
|
EnsureStorage();
|
||||||
|
File.WriteAllText(usersPath, JsonConvert.SerializeObject(users, Formatting.Indented));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Dictionary<string, string> GetLangs()
|
||||||
|
{
|
||||||
|
EnsureStorage();
|
||||||
|
return JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(langsPath)) ?? new Dictionary<string, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<DeviceRecord>())
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(d.Uid))
|
||||||
|
AccsdbUidSync.RemoveUid(d.Uid.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var d in user.Devices ?? Enumerable.Empty<DeviceRecord>())
|
||||||
|
{
|
||||||
|
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<string, TelegramUserRecord>(StringComparer.Ordinal);
|
||||||
|
var langs = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var result = new ImportResult();
|
||||||
|
|
||||||
|
if (File.Exists(legacyLangsPath))
|
||||||
|
{
|
||||||
|
langs = JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(legacyLangsPath))
|
||||||
|
?? new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
result.ImportedLangs = langs.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
var adminIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
if (File.Exists(legacyAdminsPath))
|
||||||
|
{
|
||||||
|
var rawAdmins = JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(File.ReadAllText(legacyAdminsPath))
|
||||||
|
?? new List<Dictionary<string, object>>();
|
||||||
|
|
||||||
|
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<List<LegacyTokenRecord>>(File.ReadAllText(legacyTokensPath))
|
||||||
|
?? new List<LegacyTokenRecord>();
|
||||||
|
|
||||||
|
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<DeviceRecord>()
|
||||||
|
};
|
||||||
|
users[item.telegram_id] = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var device in item.devices ?? new List<LegacyDeviceRecord>())
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<OutputType>library</OutputType>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<RootNamespace>TelegramAuth</RootNamespace>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\Shared\Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"enable": true,
|
||||||
|
"dynamic": true,
|
||||||
|
"tree": [
|
||||||
|
"Controllers",
|
||||||
|
"Models",
|
||||||
|
"Services",
|
||||||
|
"ModInit.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;
|
||||||
@@ -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<TelegramAuthBotHostedService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DeviceDto> 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<AdminUserRowDto> 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<string> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/<bot>?start=<uid>` (UID: 6–20 символов, `[a-zA-Z0-9_-]`).
|
||||||
|
4. В клиенте нажать «Проверить снова» (или аналог), который дергает `GET /tg/auth/status`.
|
||||||
|
|
||||||
|
Кнопки меню: **Мой статус**, **Мои устройства**, **Помощь**.
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
| Команда | Кто | Описание |
|
||||||
|
| ------- | --- | -------- |
|
||||||
|
| `/start` | Все | Приветствие и инструкция; вариант `/start <uid>` — быстрая привязка. |
|
||||||
|
| `/help` | Все | Подсказка по входу и кнопкам. |
|
||||||
|
| `/me` | Все | Профиль из API (роль, срок, число устройств, лимит). |
|
||||||
|
| `/devices` | Все | Список устройств; для активных — **Отвязать**, для отключённых — **✅ Включить** (снова активирует UID на сервере). |
|
||||||
|
| `/devicename` | Все | `/devicename <uid> <имя>` — имя в `Devices[].Name` через `POST /tg/auth/device/name` (только **свой** активный UID). Вместо имени `-` сбрасывает подпись. Секрет мутаций не нужен. |
|
||||||
|
| `/users` | Админы TelegramAuth | Список пользователей (постранично); в строке кратко видно **`accs`** (группа, `ban`, …). Ожидающие — **Принять** / **Отклонить**; остальные — **Выкл** / **Вкл**. |
|
||||||
|
| `/user` | Админы | `/user <telegramId>` — карточка, 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<TelegramAuthBotHostedService>`).
|
||||||
|
- 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).
|
||||||
@@ -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<UserByTelegramDto> 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<UserByTelegramDto>(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DevicesResponseDto> 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<DevicesResponseDto>(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BindCompleteResult> 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<bool?>("ok") == true,
|
||||||
|
PendingAdminApproval = jo.Value<bool?>("pendingAdminApproval") == true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> 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<bool?>("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<AdminUsersListResponseDto> 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<AdminUsersListResponseDto>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace TelegramAuthBot.Services
|
||||||
|
{
|
||||||
|
internal static class TelegramAuthBotSerilog
|
||||||
|
{
|
||||||
|
public static readonly Serilog.ILogger Log =
|
||||||
|
Serilog.Log.ForContext("SourceContext", "TelegramAuthBot");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<bool> 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,
|
||||||
|
"В конфиге бота не задан <code>mutations_api_secret</code> — тот же секрет, что <code>TelegramAuth.mutations_api_secret</code> в 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 в <code>owner_telegram_ids</code> бота (те же числа, что <code>TelegramAuth.owner_telegram_ids</code>) или пиши из чата <code>admin_chat_ids</code>. Пустой <code>admin_chat_ids</code> — админ-команды из любого чата, в т.ч. лички.",
|
||||||
|
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; ты включаешь их в <code>/users</code>.",
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
cancellationToken: ct).ConfigureAwait(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<bool> 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,
|
||||||
|
"В конфиге бота не задан <code>mutations_api_secret</code> — тот же секрет, что <code>TelegramAuth.mutations_api_secret</code> в 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 в <code>owner_telegram_ids</code> или используй чат из <code>admin_chat_ids</code>.",
|
||||||
|
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<AdminUserRowDto>();
|
||||||
|
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<string>
|
||||||
|
{
|
||||||
|
$"<b>Пользователи TelegramAuth</b> · всего {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<string>();
|
||||||
|
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 = " · <i>" + EscapeHtml(string.Join(", ", bits)) + "</i>";
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.Add($"{tag} · <code>{EscapeHtml(u.telegramId)}</code> · {st}{(adm ? " · <b>admin</b>" : "")}{accsHint}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total == 0)
|
||||||
|
lines.Add("\n<i>Записей пока нет.</i>");
|
||||||
|
|
||||||
|
var rows = new List<InlineKeyboardButton[]>();
|
||||||
|
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<InlineKeyboardButton>();
|
||||||
|
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,
|
||||||
|
"<b>Просмотр пользователя (accsdb-поля)</b>\n\n<code>/user <telegramId></code>\n\nПоказывает <b>accs</b>, срок <b>expiresAt</b>, устройства. Редактирование — <code>/setuser</code>.",
|
||||||
|
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<string>("role") ?? "—";
|
||||||
|
var lang = jo.Value<string>("lang") ?? "—";
|
||||||
|
var uname = jo.Value<string>("username");
|
||||||
|
var active = jo.Value<bool?>("active") == true;
|
||||||
|
var disabled = jo.Value<bool?>("disabled") == true;
|
||||||
|
var pending = jo.Value<bool?>("registrationPending") == true;
|
||||||
|
var exp = jo["expiresAt"]?.ToString() ?? "—";
|
||||||
|
var maxDev = jo.Value<int?>("maxDevices");
|
||||||
|
var devCount = jo.Value<int?>("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<string>();
|
||||||
|
if (jo["devices"] is JArray arr)
|
||||||
|
{
|
||||||
|
foreach (var d in arr.Take(12))
|
||||||
|
{
|
||||||
|
var uid = d.Value<string>("uid") ?? "";
|
||||||
|
var nm = d.Value<string>("name");
|
||||||
|
var act = d.Value<bool?>("active") == true;
|
||||||
|
var nmShow = string.IsNullOrEmpty(nm) ? "—" : nm;
|
||||||
|
devLines.Add($"• <code>{EscapeHtml(uid)}</code> · {EscapeHtml(nmShow)} · {(act ? "on" : "off")}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arr.Count > 12)
|
||||||
|
devLines.Add($"… ещё {arr.Count - 12}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var devBlock = devLines.Count > 0 ? string.Join("\n", devLines) : "<i>нет устройств</i>";
|
||||||
|
|
||||||
|
var msg =
|
||||||
|
$"<b>Пользователь</b> <code>{EscapeHtml(targetId)}</code>\n" +
|
||||||
|
$"@{EscapeHtml(string.IsNullOrEmpty(uname) ? "—" : uname)} · роль <b>{EscapeHtml(role)}</b> · lang {EscapeHtml(lang)}\n" +
|
||||||
|
$"активен: {(active ? "да" : "нет")} · отключён: {(disabled ? "да" : "нет")} · ожидание: {(pending ? "да" : "нет")}\n" +
|
||||||
|
$"<b>Срок (ExpiresAt):</b> <code>{EscapeHtml(exp)}</code>\n" +
|
||||||
|
$"<b>Устройства:</b> {devCount ?? 0} / {maxStr}\n\n" +
|
||||||
|
"<b>accs</b> (синхронизируется в корневой users.json):\n<pre>" + EscapeHtml(accsJson) + "</pre>\n\n" +
|
||||||
|
"<b>Устройства:</b>\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,
|
||||||
|
"<b>Изменение пользователя (accsdb)</b>\n\n" +
|
||||||
|
"<code>/setuser <telegramId> <команда> …</code>\n\n" +
|
||||||
|
"<b>Команды:</b>\n" +
|
||||||
|
"• <code>group</code> <n> или <code>clear</code>\n" +
|
||||||
|
"• <code>expires</code> <дата ISO или yyyy-MM-dd> или <code>clear</code>\n" +
|
||||||
|
"• <code>role</code> user | admin\n" +
|
||||||
|
"• <code>lang</code> <код>\n" +
|
||||||
|
"• <code>comment</code> <текст>\n" +
|
||||||
|
"• <code>banmsg</code> <текст>\n" +
|
||||||
|
"• <code>passwd</code> on | off\n" +
|
||||||
|
"• <code>ban</code> on | off — бан в accsdb (учётка TG активна); текст — <code>banmsg</code>\n" +
|
||||||
|
"• <code>ids</code> uid1,uid2 или <code>clear</code>\n" +
|
||||||
|
"• <code>param</code> ключ=значение\n" +
|
||||||
|
"• <code>clearparam</code> ключ\n" +
|
||||||
|
"• <code>clear</code> group ban ban_msg comment … — сброс полей accs\n\n" +
|
||||||
|
"Пока аккаунт <b>ожидает подтверждения</b>, UID <b>не попадает</b> в accsdb (при привязке лишний UID удаляется из users.json).\n\n" +
|
||||||
|
"После изменения при включённом sync данные уходят в корневой <code>users.json</code>.",
|
||||||
|
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 = "Неизвестная команда. Смотри <code>/setuser</code> без аргументов.";
|
||||||
|
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, $"✅ Сохранено для <code>{EscapeHtml(targetId)}</code>.", 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<int?>("importedUsers") ?? 0}, устройств: {jo.Value<int?>("importedDevices") ?? 0}, админов: {jo.Value<int?>("importedAdmins") ?? 0}, языков: {jo.Value<int?>("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<int?>("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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DeviceDto>();
|
||||||
|
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<DeviceDto>();
|
||||||
|
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, $"Устройство <code>{EscapeHtml(uid)}</code> снова активно. Открой приложение и при необходимости нажми «Проверить снова».", 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✅ <b>Подтверждено</b> — доступ включён."
|
||||||
|
: "\n\n❌ <b>Отклонено</b> — запись пользователя удалена из базы.";
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>("error");
|
||||||
|
var det = jo.Value<string>("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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 =
|
||||||
|
"🔔 <b>Новый пользователь (ожидает решения)</b>\n\n" +
|
||||||
|
$"<b>Telegram ID:</b> <code>{EscapeHtml(newUserTgId)}</code>\n" +
|
||||||
|
$"<b>Username:</b> {at}\n" +
|
||||||
|
$"<b>UID устройства:</b> <code>{EscapeHtml(deviceUid)}</code>\n\n" +
|
||||||
|
"<b>Подтвердить</b> — включить доступ. <b>Отклонить</b> — удалить запись из базы.";
|
||||||
|
|
||||||
|
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<bool> 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 =
|
||||||
|
$"✅ <b>Запрос принят</b>\n\n<code>{EscapeHtml(uid)}</code>\n\n" +
|
||||||
|
"Аккаунт создан и <b>ждёт подтверждения</b> администратора (уведомление с кнопками «Принять» / «Отклонить» или список <code>/users</code>).\n\n" +
|
||||||
|
$"После подтверждения вернись в {EscapeHtml(name)} и нажми <b>«Проверить снова»</b>.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var extra = fromStartDeepLink
|
||||||
|
? ""
|
||||||
|
: "\n\n💡 Подсказка: кнопкой <b>📱 Мои устройства</b> можно посмотреть и отвязать устройства.";
|
||||||
|
userText =
|
||||||
|
$"✅ <b>Устройство привязано</b>\n\n<code>{EscapeHtml(uid)}</code>\n\n" +
|
||||||
|
$"Вернись в {EscapeHtml(name)} и нажми <b>«Проверить снова»</b>.{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 =
|
||||||
|
$"<b>Профиль {EscapeHtml(name)}</b>\n\n" +
|
||||||
|
$"<b>Пользователь:</b> @{EscapeHtml(data.username ?? "-")}\n" +
|
||||||
|
$"<b>Telegram ID:</b> <code>{EscapeHtml(data.telegramId ?? tgId)}</code>\n" +
|
||||||
|
$"<b>Роль:</b> {EscapeHtml(data.role ?? "-")}\n" +
|
||||||
|
$"<b>Язык:</b> {EscapeHtml(data.lang ?? "-")}\n" +
|
||||||
|
$"<b>Активен:</b> {(data.active ? "да" : "нет")}{accessNote}\n" +
|
||||||
|
$"<b>Срок доступа:</b> {EscapeHtml(expires)}\n" +
|
||||||
|
$"<b>Устройств:</b> {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<DeviceDto>();
|
||||||
|
if (devices.Count == 0)
|
||||||
|
{
|
||||||
|
await bot.SendMessage(chatId, "У тебя пока нет привязанных устройств.", cancellationToken: ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = new List<string> { $"<b>Устройства @{EscapeHtml(data!.username ?? tgId)}:</b>" };
|
||||||
|
var keyboard = new List<InlineKeyboardButton[]>();
|
||||||
|
foreach (var d in devices)
|
||||||
|
{
|
||||||
|
var uid = d.uid ?? "";
|
||||||
|
var devName = string.IsNullOrEmpty(d.name) ? "без имени" : d.name;
|
||||||
|
var state = d.active ? "активно" : "отключено";
|
||||||
|
lines.Add($"• <code>{EscapeHtml(uid)}</code> — {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,
|
||||||
|
"<b>Переименование устройства</b>\n\n<code>/devicename <uid> <имя></code>\n\nUID из «Мои устройства». Чтобы сбросить имя: вместо имени отправь <code>-</code>.",
|
||||||
|
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,
|
||||||
|
"<b>Переименование устройства</b>\n\n<code>/devicename <uid> <имя></code>\n\nПример: <code>/devicename abc12xyz Телевизор в зале</code>\nСброс имени: <code>/devicename abc12xyz -</code>",
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: MainMenuKeyboard(),
|
||||||
|
cancellationToken: ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstSpace = args.IndexOf(' ');
|
||||||
|
if (firstSpace < 0)
|
||||||
|
{
|
||||||
|
await bot.SendMessage(chatId, "Нужны UID и имя. Пример: <code>/devicename abc12xyz Телевизор</code>", 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 или <code>-</code> для сброса.", 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<DeviceDto>();
|
||||||
|
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, $"✅ Имя устройства <code>{EscapeHtml(uid)}</code> — {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 =
|
||||||
|
$"❓ <b>Помощь по входу в {EscapeHtml(name)}</b>\n\n" +
|
||||||
|
"<b>Быстрый вход:</b>\n" +
|
||||||
|
$"1. Открой {EscapeHtml(name)} и дойди до экрана авторизации\n" +
|
||||||
|
"2. Скопируй UID устройства\n" +
|
||||||
|
"3. Отправь UID мне сюда\n" +
|
||||||
|
"4. Вернись в " + EscapeHtml(name) + " и нажми <b>«Проверить снова»</b>\n\n" +
|
||||||
|
"<b>Кнопки:</b>\n" +
|
||||||
|
"👤 Мой статус — профиль и срок доступа\n" +
|
||||||
|
"📱 Мои устройства — список; отвязать активные, включить отключённые\n" +
|
||||||
|
"<code>/devicename <uid> <имя></code> — имя в базе (для админки); <code>-</code> сбрасывает\n" +
|
||||||
|
"❓ Помощь — эта подсказка\n\n" +
|
||||||
|
"<b>Владелец:</b> числовой user id в <code>TelegramAuth.owner_telegram_ids</code> — при старте Lampac запись admin создаётся в базе. Остальные шлют UID; новых пользователей подтверждаешь в <code>/users</code> (Принять / Отклонить) или кнопками в уведомлении.\n\n" +
|
||||||
|
"<b>Админ-команды:</b> <code>/users</code>, <code>/user</code> <id>, <code>/setuser</code> …, <code>/import</code>, <code>/cleanup</code> + одинаковый <code>mutations_api_secret</code>." +
|
||||||
|
(conf.admin_chat_ids != null && conf.admin_chat_ids.Length > 0
|
||||||
|
? "\n\n<code>admin_chat_ids</code> задан: команды из группы — только там; из лички — если твой id в <code>owner_telegram_ids</code> бота (как на сервере)."
|
||||||
|
: "\n\nПустой <code>admin_chat_ids</code> — админ-команды из лички без доп. списков.");
|
||||||
|
await bot.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: MainMenuKeyboard(), cancellationToken: ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 =
|
||||||
|
$"✨ <b>Привет. Я бот авторизации {EscapeHtml(name)}.</b>\n\n" +
|
||||||
|
$"С моей помощью можно быстро войти в {EscapeHtml(name)} через Telegram.\n\n" +
|
||||||
|
"<b>Что я умею:</b>\n" +
|
||||||
|
"• 🔗 привязать устройство по UID\n" +
|
||||||
|
"• 👤 показать твой статус\n" +
|
||||||
|
"• 📱 показать список устройств и переименовать (<code>/devicename</code>)\n" +
|
||||||
|
"• 🗑️ отвязать устройство кнопкой\n\n" +
|
||||||
|
"<b>Как войти:</b>\n" +
|
||||||
|
$"1. Открой {EscapeHtml(name)}\n" +
|
||||||
|
"2. Скопируй UID с экрана авторизации\n" +
|
||||||
|
"3. Отправь его мне\n" +
|
||||||
|
"4. Вернись в " + EscapeHtml(name) + " и нажми <b>«Проверить снова»</b>\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 == '@';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<OutputType>library</OutputType>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<RootNamespace>TelegramAuthBot</RootNamespace>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>disable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Telegram.Bot" Version="22.4.4" />
|
||||||
|
<ProjectReference Include="..\..\..\Shared\Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="CopyTelegramBotToModReferences" AfterTargets="ResolveReferences">
|
||||||
|
<ItemGroup>
|
||||||
|
<_TelegramBotRef Include="@(ReferencePath)" Condition="'%(Filename)' == 'Telegram.Bot'" />
|
||||||
|
</ItemGroup>
|
||||||
|
<MakeDir Directories="$(MSBuildProjectDirectory)\references" />
|
||||||
|
<Copy SourceFiles="@(_TelegramBotRef)" DestinationFolder="$(MSBuildProjectDirectory)\references"
|
||||||
|
SkipUnchangedFiles="true" Condition="@(_TelegramBotRef->Count()) != 0" />
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"enable": true,
|
||||||
|
"dynamic": true,
|
||||||
|
"references": [
|
||||||
|
"references/"
|
||||||
|
],
|
||||||
|
"tree": [
|
||||||
|
"Models",
|
||||||
|
"Services",
|
||||||
|
"GlobalUsings.cs",
|
||||||
|
"ModInit.cs"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
@@ -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<WafLimitRootMap> limit_map { get; set; } = new()
|
||||||
|
{
|
||||||
|
new("^/watchtogether/", new WafLimitMap { limit = 30, second = 1 }),
|
||||||
|
new("^/wt/c/", new WafLimitMap { limit = 10, second = 1 })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WatchTogether: a lampac module that acts as both the lparty relay and a
|
||||||
|
/// shadow host keeping rooms alive when their creator disconnects.
|
||||||
|
/// </summary>
|
||||||
|
public class ModInit : IModuleLoaded, IModuleConfigure
|
||||||
|
{
|
||||||
|
public static string modpath;
|
||||||
|
public static WatchTogetherConf conf;
|
||||||
|
|
||||||
|
public void Configure(ConfigureModel app)
|
||||||
|
{
|
||||||
|
app.services.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, RelayStartupFilter>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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://<lampac>/wt/c/lparty-r-<sha256(КОД|пароль)[0:24]>` и является хостом.
|
||||||
|
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=<base64(КОД:пароль)>` и `?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://<lampac>/watch_together/?room=<invite-код>` — ссылку формирует плагин.
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class RelayStartupFilter : IStartupFilter
|
||||||
|
{
|
||||||
|
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> 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);
|
||||||
|
|
||||||
|
/// <summary>LParty peer id (the u field of the member's messages).</summary>
|
||||||
|
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<string, RelayMember> Members = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public static class RelayServer
|
||||||
|
{
|
||||||
|
static long uidSeq = 0;
|
||||||
|
static readonly ConcurrentDictionary<string, RelayChannel> Channels = new();
|
||||||
|
|
||||||
|
public static RelayChannel TryGetChannel(string name) =>
|
||||||
|
Channels.TryGetValue(name, out var channel) ? channel : null;
|
||||||
|
|
||||||
|
/// <summary>Member uids excluding the server's virtual member.</summary>
|
||||||
|
public static List<string> 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<RelayMember> 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<RelayMember>();
|
||||||
|
total = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
channel.Members[member.Uid] = member;
|
||||||
|
users = new List<RelayMember>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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<byte>(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<byte>(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<byte>(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<RelayMember> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>Room code as announced by the host in the lobby (ad frames);
|
||||||
|
/// lets the server list and re-announce rooms it keeps alive.</summary>
|
||||||
|
public string Code;
|
||||||
|
|
||||||
|
// server-side hold barrier (only while Hosting)
|
||||||
|
public HashSet<string> HoldWaiting;
|
||||||
|
public double HoldPosition;
|
||||||
|
public Timer HoldTimer;
|
||||||
|
|
||||||
|
public DateTime CreatedUtc = DateTime.UtcNow;
|
||||||
|
public DateTime LastActivityUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Display name of the member that announced the room.</summary>
|
||||||
|
public string OwnerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A room advertisement overheard in the lobby channel.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string, ShadowRoom> rooms = new();
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
static readonly ConcurrentDictionary<string, LobbyAd> lobbyAds = new();
|
||||||
|
|
||||||
|
/// <summary>pid -> last hello, per channel; used to skip the join hold
|
||||||
|
/// for members that are reconnecting rather than new.</summary>
|
||||||
|
static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, long>> knownPids = new();
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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
|
||||||
|
|
||||||
|
/// <summary>Whether the server's virtual member must exist in this
|
||||||
|
/// channel right now (it is the acting host).</summary>
|
||||||
|
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<string, long>());
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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<string>(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<string, long>());
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Server-hosting room, newcomer arrived: introduce the room
|
||||||
|
/// and, like a real host, freeze it while they buffer.</summary>
|
||||||
|
static void AnswerHelloAsHost(ShadowRoom room, RelayChannel channel, RelayMember member, ConcurrentDictionary<string, long> 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<string>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Freeze the clock base at the current position.</summary>
|
||||||
|
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
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\Shared\Shared.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Core\Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="manifest.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="plugin.js">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Include="web\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
[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<string, string> transform)
|
||||||
|
{
|
||||||
|
string path = Path.Combine(ModInit.modpath, name);
|
||||||
|
if (!System.IO.File.Exists(path))
|
||||||
|
return Content("<html><body><h2>" + name + " not found</h2></body></html>", "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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"enable": true,
|
||||||
|
"dynamic": true,
|
||||||
|
"tree": [
|
||||||
|
"ModInit.cs",
|
||||||
|
"RelayServer.cs",
|
||||||
|
"ShadowHost.cs",
|
||||||
|
"WatchTogetherController.cs"
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,386 @@
|
|||||||
|
/**
|
||||||
|
* Friendly names for a participant — "Clever Mango" rather than a random id.
|
||||||
|
*
|
||||||
|
* The idea, the adjective-then-greengrocer shape and the word lists come from LocalSend
|
||||||
|
* (Apache License 2.0) by way of the JAPP player, which solves the same problem: something
|
||||||
|
* a stranger can read off a screen and repeat out loud, that says nothing about the device
|
||||||
|
* or its owner. 222 x 137 is 30414, which puts two people in a room of five
|
||||||
|
* drawing the same name at around one chance in three thousand.
|
||||||
|
*
|
||||||
|
* Deliberately not localised, exactly as LocalSend is not, so a name generated here reads
|
||||||
|
* the same as one generated in the Android player.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ADJECTIVES = [
|
||||||
|
"Able",
|
||||||
|
"Active",
|
||||||
|
"Adorable",
|
||||||
|
"Agile",
|
||||||
|
"Alert",
|
||||||
|
"Amber",
|
||||||
|
"Amused",
|
||||||
|
"Ancient",
|
||||||
|
"Artful",
|
||||||
|
"Autumn",
|
||||||
|
"Balanced",
|
||||||
|
"Beautiful",
|
||||||
|
"Big",
|
||||||
|
"Bold",
|
||||||
|
"Brave",
|
||||||
|
"Breezy",
|
||||||
|
"Bright",
|
||||||
|
"Brisk",
|
||||||
|
"Calm",
|
||||||
|
"Candid",
|
||||||
|
"Careful",
|
||||||
|
"Cheerful",
|
||||||
|
"Chosen",
|
||||||
|
"Civil",
|
||||||
|
"Classic",
|
||||||
|
"Clean",
|
||||||
|
"Clear",
|
||||||
|
"Clever",
|
||||||
|
"Cool",
|
||||||
|
"Cosmic",
|
||||||
|
"Crafty",
|
||||||
|
"Crimson",
|
||||||
|
"Crisp",
|
||||||
|
"Cunning",
|
||||||
|
"Curious",
|
||||||
|
"Cute",
|
||||||
|
"Dapper",
|
||||||
|
"Daring",
|
||||||
|
"Dazzling",
|
||||||
|
"Deep",
|
||||||
|
"Deft",
|
||||||
|
"Determined",
|
||||||
|
"Devoted",
|
||||||
|
"Diligent",
|
||||||
|
"Distant",
|
||||||
|
"Divine",
|
||||||
|
"Eager",
|
||||||
|
"Early",
|
||||||
|
"Earnest",
|
||||||
|
"Easy",
|
||||||
|
"Efficient",
|
||||||
|
"Elder",
|
||||||
|
"Electric",
|
||||||
|
"Elegant",
|
||||||
|
"Endless",
|
||||||
|
"Energetic",
|
||||||
|
"Epic",
|
||||||
|
"Equal",
|
||||||
|
"Eternal",
|
||||||
|
"Exact",
|
||||||
|
"Fabled",
|
||||||
|
"Faithful",
|
||||||
|
"Famous",
|
||||||
|
"Fantastic",
|
||||||
|
"Fast",
|
||||||
|
"Fearless",
|
||||||
|
"Feisty",
|
||||||
|
"Fertile",
|
||||||
|
"Festive",
|
||||||
|
"Fiery",
|
||||||
|
"Fine",
|
||||||
|
"Firm",
|
||||||
|
"Fond",
|
||||||
|
"Frank",
|
||||||
|
"Free",
|
||||||
|
"Fresh",
|
||||||
|
"Friendly",
|
||||||
|
"Frosty",
|
||||||
|
"Gallant",
|
||||||
|
"Gentle",
|
||||||
|
"Genuine",
|
||||||
|
"Gifted",
|
||||||
|
"Glad",
|
||||||
|
"Gleaming",
|
||||||
|
"Glossy",
|
||||||
|
"Golden",
|
||||||
|
"Good",
|
||||||
|
"Gorgeous",
|
||||||
|
"Graceful",
|
||||||
|
"Grand",
|
||||||
|
"Grateful",
|
||||||
|
"Great",
|
||||||
|
"Handsome",
|
||||||
|
"Happy",
|
||||||
|
"Hardy",
|
||||||
|
"Hearty",
|
||||||
|
"Helpful",
|
||||||
|
"Heroic",
|
||||||
|
"Hidden",
|
||||||
|
"Honest",
|
||||||
|
"Hopeful",
|
||||||
|
"Hot",
|
||||||
|
"Humble",
|
||||||
|
"Ideal",
|
||||||
|
"Immense",
|
||||||
|
"Inner",
|
||||||
|
"Iron",
|
||||||
|
"Jolly",
|
||||||
|
"Joyful",
|
||||||
|
"Keen",
|
||||||
|
"Kind",
|
||||||
|
"Lively",
|
||||||
|
"Lovely",
|
||||||
|
"Loyal",
|
||||||
|
"Lucid",
|
||||||
|
"Lucky",
|
||||||
|
"Lunar",
|
||||||
|
"Magic",
|
||||||
|
"Major",
|
||||||
|
"Merry",
|
||||||
|
"Mighty",
|
||||||
|
"Mild",
|
||||||
|
"Modern",
|
||||||
|
"Modest",
|
||||||
|
"Mystic",
|
||||||
|
"Neat",
|
||||||
|
"Nice",
|
||||||
|
"Nimble",
|
||||||
|
"Noble",
|
||||||
|
"Northern",
|
||||||
|
"Novel",
|
||||||
|
"Open",
|
||||||
|
"Orbital",
|
||||||
|
"Original",
|
||||||
|
"Patient",
|
||||||
|
"Peaceful",
|
||||||
|
"Perfect",
|
||||||
|
"Playful",
|
||||||
|
"Pleasant",
|
||||||
|
"Polished",
|
||||||
|
"Polite",
|
||||||
|
"Powerful",
|
||||||
|
"Precise",
|
||||||
|
"Pretty",
|
||||||
|
"Prime",
|
||||||
|
"Proud",
|
||||||
|
"Prudent",
|
||||||
|
"Quick",
|
||||||
|
"Quiet",
|
||||||
|
"Radiant",
|
||||||
|
"Rapid",
|
||||||
|
"Ready",
|
||||||
|
"Refined",
|
||||||
|
"Regal",
|
||||||
|
"Reliable",
|
||||||
|
"Rich",
|
||||||
|
"Robust",
|
||||||
|
"Royal",
|
||||||
|
"Ruby",
|
||||||
|
"Rustic",
|
||||||
|
"Sage",
|
||||||
|
"Scarlet",
|
||||||
|
"Secret",
|
||||||
|
"Serene",
|
||||||
|
"Sharp",
|
||||||
|
"Shining",
|
||||||
|
"Silent",
|
||||||
|
"Silver",
|
||||||
|
"Sincere",
|
||||||
|
"Skilled",
|
||||||
|
"Sleek",
|
||||||
|
"Smart",
|
||||||
|
"Smooth",
|
||||||
|
"Snowy",
|
||||||
|
"Solar",
|
||||||
|
"Solid",
|
||||||
|
"Southern",
|
||||||
|
"Special",
|
||||||
|
"Spirited",
|
||||||
|
"Splendid",
|
||||||
|
"Spry",
|
||||||
|
"Stately",
|
||||||
|
"Steady",
|
||||||
|
"Sterling",
|
||||||
|
"Stormy",
|
||||||
|
"Strategic",
|
||||||
|
"Strong",
|
||||||
|
"Sturdy",
|
||||||
|
"Subtle",
|
||||||
|
"Sunny",
|
||||||
|
"Superb",
|
||||||
|
"Supreme",
|
||||||
|
"Swift",
|
||||||
|
"Tactful",
|
||||||
|
"Tender",
|
||||||
|
"Thankful",
|
||||||
|
"Thoughtful",
|
||||||
|
"Tidy",
|
||||||
|
"Timely",
|
||||||
|
"Tranquil",
|
||||||
|
"True",
|
||||||
|
"Trusty",
|
||||||
|
"Unique",
|
||||||
|
"Upbeat",
|
||||||
|
"Valiant",
|
||||||
|
"Velvet",
|
||||||
|
"Vibrant",
|
||||||
|
"Vigilant",
|
||||||
|
"Violet",
|
||||||
|
"Vivid",
|
||||||
|
"Warm",
|
||||||
|
"Watchful",
|
||||||
|
"Welcome",
|
||||||
|
"Whole",
|
||||||
|
"Wild",
|
||||||
|
"Willing",
|
||||||
|
"Wise",
|
||||||
|
"Witty",
|
||||||
|
"Wondrous",
|
||||||
|
"Worthy",
|
||||||
|
"Zealous",
|
||||||
|
"Zesty",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FRUITS = [
|
||||||
|
"Almond",
|
||||||
|
"Apple",
|
||||||
|
"Apricot",
|
||||||
|
"Artichoke",
|
||||||
|
"Arugula",
|
||||||
|
"Asparagus",
|
||||||
|
"Aubergine",
|
||||||
|
"Avocado",
|
||||||
|
"Banana",
|
||||||
|
"Barley",
|
||||||
|
"Basil",
|
||||||
|
"Beetroot",
|
||||||
|
"Blackberry",
|
||||||
|
"Blackcurrant",
|
||||||
|
"Blueberry",
|
||||||
|
"Broccoli",
|
||||||
|
"Cabbage",
|
||||||
|
"Cantaloupe",
|
||||||
|
"Caper",
|
||||||
|
"Cardamom",
|
||||||
|
"Carrot",
|
||||||
|
"Cashew",
|
||||||
|
"Cauliflower",
|
||||||
|
"Celery",
|
||||||
|
"Chard",
|
||||||
|
"Cherry",
|
||||||
|
"Chestnut",
|
||||||
|
"Chickpea",
|
||||||
|
"Chicory",
|
||||||
|
"Chili",
|
||||||
|
"Chive",
|
||||||
|
"Cilantro",
|
||||||
|
"Clementine",
|
||||||
|
"Cloudberry",
|
||||||
|
"Clove",
|
||||||
|
"Coconut",
|
||||||
|
"Cranberry",
|
||||||
|
"Cucumber",
|
||||||
|
"Currant",
|
||||||
|
"Damson",
|
||||||
|
"Date",
|
||||||
|
"Dill",
|
||||||
|
"Durian",
|
||||||
|
"Eggplant",
|
||||||
|
"Elderberry",
|
||||||
|
"Endive",
|
||||||
|
"Fennel",
|
||||||
|
"Fig",
|
||||||
|
"Garlic",
|
||||||
|
"Ginger",
|
||||||
|
"Gooseberry",
|
||||||
|
"Grape",
|
||||||
|
"Grapefruit",
|
||||||
|
"Guava",
|
||||||
|
"Hazelnut",
|
||||||
|
"Honeydew",
|
||||||
|
"Horseradish",
|
||||||
|
"Jackfruit",
|
||||||
|
"Jicama",
|
||||||
|
"Kale",
|
||||||
|
"Kiwi",
|
||||||
|
"Kohlrabi",
|
||||||
|
"Kumquat",
|
||||||
|
"Leek",
|
||||||
|
"Lemon",
|
||||||
|
"Lentil",
|
||||||
|
"Lettuce",
|
||||||
|
"Lime",
|
||||||
|
"Longan",
|
||||||
|
"Loquat",
|
||||||
|
"Lychee",
|
||||||
|
"Mandarin",
|
||||||
|
"Mango",
|
||||||
|
"Marrow",
|
||||||
|
"Melon",
|
||||||
|
"Millet",
|
||||||
|
"Mint",
|
||||||
|
"Mulberry",
|
||||||
|
"Mushroom",
|
||||||
|
"Nectarine",
|
||||||
|
"Nutmeg",
|
||||||
|
"Oat",
|
||||||
|
"Okra",
|
||||||
|
"Olive",
|
||||||
|
"Onion",
|
||||||
|
"Orange",
|
||||||
|
"Oregano",
|
||||||
|
"Papaya",
|
||||||
|
"Paprika",
|
||||||
|
"Parsley",
|
||||||
|
"Parsnip",
|
||||||
|
"Passionfruit",
|
||||||
|
"Pea",
|
||||||
|
"Peach",
|
||||||
|
"Peanut",
|
||||||
|
"Pear",
|
||||||
|
"Pecan",
|
||||||
|
"Pepper",
|
||||||
|
"Persimmon",
|
||||||
|
"Pineapple",
|
||||||
|
"Pistachio",
|
||||||
|
"Plantain",
|
||||||
|
"Plum",
|
||||||
|
"Pomegranate",
|
||||||
|
"Pomelo",
|
||||||
|
"Poppy",
|
||||||
|
"Potato",
|
||||||
|
"Pumpkin",
|
||||||
|
"Quince",
|
||||||
|
"Radish",
|
||||||
|
"Raisin",
|
||||||
|
"Raspberry",
|
||||||
|
"Rhubarb",
|
||||||
|
"Rosemary",
|
||||||
|
"Rutabaga",
|
||||||
|
"Saffron",
|
||||||
|
"Sesame",
|
||||||
|
"Shallot",
|
||||||
|
"Sorrel",
|
||||||
|
"Soybean",
|
||||||
|
"Spinach",
|
||||||
|
"Sprout",
|
||||||
|
"Squash",
|
||||||
|
"Strawberry",
|
||||||
|
"Tamarind",
|
||||||
|
"Tangerine",
|
||||||
|
"Tarragon",
|
||||||
|
"Thyme",
|
||||||
|
"Tomato",
|
||||||
|
"Turnip",
|
||||||
|
"Vanilla",
|
||||||
|
"Walnut",
|
||||||
|
"Wasabi",
|
||||||
|
"Watercress",
|
||||||
|
"Watermelon",
|
||||||
|
"Yam",
|
||||||
|
"Zucchini",
|
||||||
|
];
|
||||||
|
|
||||||
|
const pick = (list) =>
|
||||||
|
list[crypto.getRandomValues(new Uint32Array(1))[0] % list.length];
|
||||||
|
|
||||||
|
export function randomAlias() {
|
||||||
|
return pick(ADJECTIVES) + " " + pick(FRUITS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALIAS_WORDS = { ADJECTIVES, FRUITS };
|
||||||
Reference in New Issue
Block a user