- Introduced a new support ticket system allowing users to submit bug reports and role requests. - Implemented endpoints for creating, updating, and managing support tickets, including file attachments. - Enhanced Telegram bot integration to handle role requests directly within the bot, enabling admins to approve or reject requests without accessing the website. - Updated database schema to include support ticket entities and their relationships. - Improved API documentation to reflect new support ticket endpoints and their usage. - Added necessary localization for support ticket features in both Russian and English.
266 lines
27 KiB
Markdown
266 lines
27 KiB
Markdown
# Telegram Bot
|
||
|
||
Telegram-бот — **второй канал доставки** (presentation-адаптер) поверх той же Application-логики,
|
||
что и REST API. Он не содержит бизнес-правил: обработчики апдейтов вызывают те же CQRS-команды/запросы
|
||
(`ICommand`/`IQuery` через собственный `ISender`), что и веб. Бизнес-инварианты живут в домене.
|
||
|
||
## Возможности (реализовано)
|
||
|
||
1. **Мои конфиги** — `/configs` (или кнопка «📋 Мои конфиги» из главного меню) присылает **одно**
|
||
сообщение со списком (метка/локация, протокол, статус) и по кнопке `🔗 {Label}` на каждый активный
|
||
конфиг + кнопкой **«🔙 В меню»** внизу. Нажатие на конфиг **редактирует то же сообщение**: дописывает
|
||
connection string моноширинным блоком (тап = копирование целиком) и убирает именно эту кнопку —
|
||
остальные конфиги и «В меню» остаются на месте. Ничего не светится без явного нажатия, отдельных
|
||
сообщений не плодится. Только текстовая ссылка, без QR-картинки — за QR пользователь идёт на сайт.
|
||
Доступно только привязанному аккаунту.
|
||
2. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: инициируется на сайте,
|
||
подтверждается в боте кнопками «Подтвердить/Отклонить». Требует предварительной привязки Telegram.
|
||
3. **Админ: обработка запросов активации** — админ (по Telegram id из `Telegram__AdminTelegramUserIds`)
|
||
получает сообщение о каждом запросе активации с именем и комментарием заявителя, жмёт
|
||
«✅ Активировать / ❌ Отклонить» прямо в сообщении. `/requests` показывает все ожидающие запросы по требованию.
|
||
4. **DM-уведомления пользователю** (если Telegram привязан): активация аккаунта, блокировка,
|
||
принудительный отзыв конфига админом.
|
||
5. **Отвязка** — `/unlink` или кнопка «🔓 Отвязать Telegram» из главного меню (редактирует то же
|
||
сообщение в подтверждение + меню для непривязанного состояния).
|
||
6. **Регистрация прямо из бота** — кнопка «📝 Зарегистрироваться» показывается там, где боту нужен
|
||
привязанный аккаунт, а Telegram ещё не привязан (`/start`, `/help`, `/configs`, запрос passwordless-
|
||
входа). Логин — `@username` из Telegram; если его нет или он уже занят на сайте — используется
|
||
Telegram id (гарантированно уникален). Пароль генерируется и присылается в чат один раз — сохраните
|
||
его сразу, при желании логин и пароль можно сменить в Настройках на сайте. Новый аккаунт получает
|
||
роль `user` и `IsActivated = false` — активация нужна как для обычной регистрации на сайте.
|
||
7. **Главное меню** — `/start`/`/help` показывают одно сообщение с кнопками вместо текстового списка
|
||
команд: привязанному аккаунту — «📋 Мои конфиги» / «🔓 Отвязать Telegram», непривязанному — «📝
|
||
Зарегистрироваться»; плюс кнопка «🌐 Сайт панели» со ссылкой на сайт, если задан `Telegram__PublicSiteUrl`
|
||
(пусто — кнопки нет). Слэш-команды `/configs`/`/unlink` продолжают работать как раньше — кнопки лишь
|
||
вызывают те же обработчики через callback (`menu:configs`/`menu:unlink`/`menu:back`).
|
||
8. **Админ: обработка заявок на роль поддержки** — при новой заявке (`SupportTicket.Type ==
|
||
RoleRequest`) админ получает сообщение с описанием (существующая роль либо параметры новой) и
|
||
обоснованием, жмёт «✅ Одобрить / ❌ Отклонить» **прямо в Telegram, без захода на сайт** — одобрение
|
||
создаёт роль (если новая) и назначает её пользователю той же командой, что и на сайте. Баг-репорты/
|
||
предложения — только уведомление с кнопкой-ссылкой на сайт, без инлайн-действий (переписка и
|
||
вложения удобнее там).
|
||
|
||
**Не реализовано:**
|
||
- QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке).
|
||
- Отдельная команда `/resetpassword` с одноразовой ссылкой — восстановление пароля сейчас идёт
|
||
только через обычный passwordless-вход (`/start login_<n>`) + смену пароля в настройках на сайте.
|
||
- Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет.
|
||
- Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам (только
|
||
просмотр списка и показ существующей ссылки по кнопке).
|
||
- Баг-репорты/предложения тикетов поддержки **не решаются из бота** (только уведомление-ссылка) —
|
||
ответы, вложения, resolve/close только на сайте.
|
||
|
||
## Размещение в архитектуре
|
||
|
||
- Бот работает **в том же процессе**, что и API, как `BackgroundService` (`TelegramBotHostedService`,
|
||
`PnvPanel.Api/Telegram/`) — условие «фронт+бек в одном контейнере».
|
||
- Транспорт — **только long polling** (`ITelegramBotClient.ReceiveAsync`). Webhook рассматривался на
|
||
этапе планирования, но не реализован: `TelegramOptions` (`Infrastructure/Telegram/TelegramOptions.cs`)
|
||
содержит только `BotToken`, `ProxyUrl`, `PublicSiteUrl`, `AdminTelegramUserIds` — полей
|
||
`Mode`/`WebhookUrl`/`WebhookSecret` в коде нет.
|
||
- Библиотека — **Telegram.Bot**. Каждый апдейт обрабатывается в своём DI-scope (`PnvBotUpdateHandler`,
|
||
как HTTP-запрос — свежие scoped-сервисы на апдейт).
|
||
- Обращения к домену — **только** через `ISender`. `Telegram.Bot` не проникает в Application/Domain.
|
||
- Если `Telegram:BotToken` не задан — `TelegramBotHostedService.ExecuteAsync` сразу возвращается,
|
||
бот не стартует, панель работает без него (лог `Telegram__BotToken не задан — бот не стартует.`).
|
||
|
||
```
|
||
Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandler (Api/Telegram/)
|
||
│ ISender.Send(command/query) // свой диспетчер, свой DI-scope на апдейт
|
||
▼
|
||
Application (те же хендлеры, что и REST)
|
||
```
|
||
|
||
## Модель данных (добавления)
|
||
|
||
- `AppUser.TelegramUserId : long?`, `TelegramUsername : string?`, `TelegramLinkedAt : DateTimeOffset?`.
|
||
- `TelegramLinkToken` — короткоживущий одноразовый токен привязки (`token`, `userId`, `expiresAt`, `consumedAt`).
|
||
- `TelegramLoginRequest` — запрос passwordless-входа: `id`, `status`
|
||
(`Pending/Approved/Rejected/Expired/Consumed`), `userId?` (после подтверждения), `context?`
|
||
(IP инициатора, собирается, но **в текст подтверждения в боте не выводится** — известный TODO),
|
||
`createdAt`, `expiresAt`.
|
||
|
||
Подробности полей — в [domain-model.md](domain-model.md).
|
||
|
||
## Флоу 1 — Привязка Telegram к аккаунту
|
||
|
||
Предусловие: пользователь уже вошёл на сайте.
|
||
|
||
1. На сайте «Привязать Telegram» → `POST /api/auth/telegram/link-token` → `{ deepLink, expiresAt }`,
|
||
`deepLink` вида `https://t.me/<bot>?start=link_<token>`. Фронт рисует QR из `deepLink` сам
|
||
(`qrcode.react`) — бэкенд картинку не генерирует.
|
||
2. Пользователь открывает бота по ссылке → `/start link_<token>`.
|
||
3. Бот берёт `from.id`, вызывает `LinkTelegramCommand(token, telegramUserId, telegramUsername)` —
|
||
валидирует токен, проставляет `TelegramUserId`/`TelegramUsername`/`TelegramLinkedAt`, гасит токен.
|
||
4. Бот отвечает «✅ Telegram успешно привязан к вашему аккаунту» (или текст ошибки). Сайт узнаёт об
|
||
успехе поллингом статуса активации/профиля.
|
||
|
||
Инварианты: один `TelegramUserId` ↔ один аккаунт; токен одноразовый и истекает.
|
||
|
||
## Флоу 2 — Passwordless-вход через бота
|
||
|
||
Предусловие: Telegram уже привязан к аккаунту.
|
||
|
||
1. На сайте «Войти через Telegram» → `POST /api/auth/telegram/login-request` →
|
||
`{ requestId, deepLink, expiresAt }`. Сайт начинает поллить
|
||
`GET /api/auth/telegram/login-request/{requestId}`.
|
||
2. Пользователь открывает `https://t.me/<bot>?start=login_<requestId>` → бот по `from.id` находит
|
||
привязанный аккаунт (если не найден — просит сначала привязать) и показывает сообщение
|
||
«Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?» с инлайн-кнопками
|
||
**«✅ Подтвердить вход» / «❌ Отклонить»**.
|
||
3. Подтверждение → `ApproveTelegramLoginCommand`/`RejectTelegramLoginCommand` → запрос переходит в
|
||
`Approved`/`Rejected`.
|
||
4. Сайт по следующему поллингу получает результат: при `Approved` — `accessToken` в теле,
|
||
`refresh` уже пришёл в httpOnly cookie (та же логика cookie, что и обычный логин, включая
|
||
`Secure = request.IsHttps`). Запрос помечается `Consumed`.
|
||
|
||
Если Telegram **не привязан** — подтвердить вход невозможно; бот присылает `NotLinkedMessage`
|
||
(«Сначала зарегистрируйтесь и войдите на сайте, затем привяжите Telegram...») с кнопкой
|
||
«📝 Зарегистрироваться» — см. Флоу 3.
|
||
|
||
## Флоу 3 — Регистрация прямо из бота
|
||
|
||
Показывается кнопкой «📝 Зарегистрироваться» везде, где боту нужен привязанный аккаунт, а его нет
|
||
(`/start`, `/help`, `/configs`, запрос passwordless-входа для непривязанного Telegram).
|
||
|
||
1. Нажатие → callback `reg:new` → `RegisterViaTelegramCommand(telegramUserId, telegramUsername)`.
|
||
2. Если `TelegramUserId` уже привязан к какому-то аккаунту — `TelegramErrors.AlreadyLinked`, регистрация
|
||
не создаёт второй аккаунт.
|
||
3. Логин: пробуем `@username` из Telegram (`identityService.CreateUserAsync`); если username пуст или
|
||
занят на сайте — используем `TelegramUserId.ToString()` (гарантированно уникален). Пароль генерируется
|
||
(`RandomNumberGenerator`, 12 символов, гарантированы заглавная/строчная буква и цифра под текущую
|
||
политику пароля) и присылается в чат **один раз**, отдельным HTML-сообщением (`<code>`).
|
||
4. Сразу после создания — `identityService.LinkTelegramAsync(...)`, аккаунт уже привязан, без
|
||
отдельного шага как во Флоу 1.
|
||
5. Новый аккаунт — роль `user`, `IsActivated = false`: активация нужна как для обычной регистрации на
|
||
сайте, `/configs` будет недоступен до неё.
|
||
6. Логин можно сменить в Настройках на сайте (`ChangeUserNameCommand`, `POST /api/auth/change-username`)
|
||
— актуально, если логином стал Telegram id.
|
||
|
||
## Флоу 4 — Просмотр конфигов в боте
|
||
|
||
1. Привязанный пользователь: `/configs` (текстовая команда → новое сообщение) или «📋 Мои конфиги» из
|
||
главного меню (callback `menu:configs` → редактирует текущее сообщение, см. `BuildConfigsMenuAsync`).
|
||
2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`.
|
||
3. Текст — **одно** сообщение: `Ваши конфиги:` + по строке `• {Label ?? Location} ({Protocol}) — {Status}`.
|
||
Клавиатура — по кнопке `🔗 {Label}` на каждый **не отозванный** конфиг + «🔙 В меню» внизу. Если
|
||
конфигов нет — «У вас пока нет конфигов.» с той же кнопкой «В меню».
|
||
4. Нажатие `🔗 {Label}` → callback `cfg:link:{configId}` → бот вызывает `GetConfigLinkQuery` (тот же,
|
||
что эндпоинт `/api/configs/{id}/link`) от текущего пользователя и **редактирует то же сообщение**
|
||
(`EditMessageText`): дописывает ссылку моноширинным блоком (`<code>`, тап = копирование целиком) и
|
||
убирает **именно эту** кнопку из клавиатуры (`InlineKeyboardMarkup.InlineKeyboard`, фильтр по
|
||
`CallbackData`) — остальные конфиги и «В меню» остаются кликабельными. Ссылка не раскрывается нигде
|
||
до явного нажатия, отдельных сообщений не плодится. QR-картинки нет — только текст.
|
||
5. «🔙 В меню» (`menu:back`) — редактирует сообщение обратно в главное меню (`BuildMainMenu`).
|
||
|
||
## Флоу 5 — Обработка активации админом в боте
|
||
|
||
1. Пользователь отправляет запрос активации (сайт: `POST /api/activation/request { comment }`) →
|
||
`RequestActivationCommandHandler` шлёт SignalR `activationRequested` группе `admins` **и** вызывает
|
||
`ITelegramNotifier.NotifyAdminsActivationRequestedAsync` (прямой вызов из хендлера, без диспетчера событий).
|
||
2. Каждому админу (по `Telegram__AdminTelegramUserIds`) уходит сообщение с именем и комментарием
|
||
заявителя и кнопками **«✅ Активировать / ❌ Отклонить»**.
|
||
3. Нажатие → `ApproveActivationCommand`/`RejectActivationCommand` (те же, что на сайте) →
|
||
пользователь активируется/отклоняется, ему уходит realtime `userActivated` (только при одобрении) +
|
||
Telegram-DM, если привязан; нажавшему админу приходит короткое подтверждение («✅ Пользователь
|
||
активирован.»/«❌ Запрос отклонён.») — само сообщение с кнопками не редактируется.
|
||
4. Проверка прав — на стороне бота (`TrySetAdminCurrentUserAsync`) перед вызовом команды: Telegram id
|
||
должен быть в `Telegram__AdminTelegramUserIds`.
|
||
|
||
`/requests` — тот же список запросов по требованию (до 10 штук, `Pending`), с теми же кнопками; для
|
||
кого он доступен — та же проверка админ-id.
|
||
|
||
## Флоу 6 — Обращения в поддержку
|
||
|
||
Два разных сценария в зависимости от типа тикета (`SupportTicket.Type`):
|
||
|
||
**Баг-репорт/предложение** — только уведомление, без действий в боте:
|
||
1. `CreateBugReportTicketCommandHandler` вызывает
|
||
`ITelegramNotifier.NotifyAdminsBugReportCreatedAsync(ticketId, userName, message, ct)`.
|
||
2. Каждому админу уходит сообщение с превью текста (обрезано до ~300 символов) и, если задан
|
||
`Telegram__PublicSiteUrl`, **кнопкой-ссылкой** `🌐 Открыть на сайте` на `/admin/support/{ticketId}`
|
||
(`InlineKeyboardButton.WithUrl`, не callback) — тап открывает страницу тикета в браузере.
|
||
3. Дальше — только на сайте: переписка, вложения, resolve/close.
|
||
|
||
**Заявка на роль** — полностью решается в Telegram:
|
||
1. `CreateRoleRequestTicketCommandHandler` вызывает
|
||
`ITelegramNotifier.NotifyAdminsRoleRequestCreatedAsync(ticketId, userName, roleDescription, justification, ct)`
|
||
— `roleDescription` уже готовая строка (имя существующей роли либо «новая роль «X» (конфигов: N, IP: M)»).
|
||
2. Сообщение с кнопками **«✅ Одобрить» / «❌ Отклонить»** (callback `rrq:approve:{id}`/`rrq:reject:{id}`
|
||
— тот же 3-частный формат `prefix:action:guid`, что и `act:*` для активации).
|
||
3. Нажатие → проверка прав (`TrySetAdminCurrentUserAsync`, тот же, что для активации) →
|
||
`ApproveRoleRequestCommand`/`RejectRoleRequestCommand` (те же команды, что дёргает
|
||
`POST /api/admin/support/tickets/{id}/approve|reject` на сайте). При одобрении — если роль новая,
|
||
сперва создаётся `AppRole`, затем в любом случае назначается пользователю; тикет переходит в
|
||
`Resolved`/`Closed`. Нажавшему админу — короткое подтверждение, исходное сообщение редактируется
|
||
(дописывается статус), как и у `act:*`.
|
||
4. Пользователю (если Telegram привязан) — DM «✅ Ваша заявка на роль одобрена.» / «❌ Ваша заявка на
|
||
роль отклонена.».
|
||
|
||
## Команды и клавиатуры
|
||
|
||
| Команда / кнопка | Действие | Требует привязки |
|
||
| -------------------------- | -------------------------------------------------------------- | ----------------- |
|
||
| `/start` | Приветствие + справка по командам | нет |
|
||
| `/start link_<token>` | Привязка аккаунта по токену | нет |
|
||
| `/start login_<requestId>` | Подтверждение passwordless-входа (deep-link с сайта) | да |
|
||
| `/configs`, «📋 Мои конфиги» (`menu:configs`) | Список конфигов, кнопка `🔗 {Label}` на каждый активный + «🔙 В меню» | да |
|
||
| `/unlink`, «🔓 Отвязать Telegram» (`menu:unlink`) | Отвязать Telegram от аккаунта | да |
|
||
| «🔙 В меню» (`menu:back`) | Вернуться из списка конфигов к главному меню (edit-in-place) | нет |
|
||
| «🌐 Сайт панели» | Открыть сайт (`InlineKeyboardButton.WithUrl`, только если задан `Telegram__PublicSiteUrl`) | нет |
|
||
| `/help` | Справка (то же сообщение, что `/start`) | нет |
|
||
| «📝 Зарегистрироваться» (`reg:new`) | Регистрация нового аккаунта прямо из бота (Флоу 3) | нет (нужно, чтобы **не** был привязан) |
|
||
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
|
||
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
|
||
| «✅ Одобрить»/«❌ Отклонить» (`rrq:*`) | (admin) решение по заявке на роль — создаёт/назначает роль | админ по env |
|
||
| «🌐 Открыть на сайте» | Ссылка на баг-репорт на сайте (только если задан `Telegram__PublicSiteUrl`) | админ по env |
|
||
|
||
Главное меню (`/start`/`/help`) — см. пункт 7 в «Возможности» выше.
|
||
|
||
Любой другой текст → «Не понимаю эту команду. /help — список команд.»
|
||
|
||
## Безопасность
|
||
|
||
- Токены привязки и `requestId` входа: высокоэнтропийные, короткоживущие, одноразовые (см.
|
||
`TelegramLinkToken`/`TelegramLoginRequest` в [domain-model.md](domain-model.md) — точный TTL не
|
||
вынесен в отдельное конфигурируемое значение, см. [tech-stack.md](tech-stack.md)).
|
||
- Подтверждение входа **не показывает** контекст (время/IP/устройство) инициатора — поле `Context`
|
||
собирается (`CreateLoginRequestCommand`), но в текст сообщения бота не подставляется. Если это
|
||
важно для защиты от фишинга — доработка на будущее, не текущее поведение.
|
||
- Транспорт — только long polling: прямой канал к Bot API по TLS, без верификации webhook-заголовка
|
||
(webhook не реализован).
|
||
- `Telegram:BotToken` — секрет (env/secret-store), в логи не попадает; при пустом токене
|
||
`TelegramBotClient` конструируется с синтаксической заглушкой вместо падения при старте — реальный
|
||
HTTP-вызов всё равно не происходит, т.к. `TelegramBotHostedService` и `TelegramNotifier` сами
|
||
проверяют `BotToken` перед использованием клиента.
|
||
- Passwordless-вход выпускает те же JWT/refresh, что и обычный (тот же `AuthResult`, та же cookie-логика).
|
||
- Явного rate-limit на команды бота нет (в отличие от HTTP-эндпоинтов `/api/auth/*`).
|
||
|
||
## Конфигурация
|
||
|
||
Реальные поля `TelegramOptions` (секция `Telegram`):
|
||
|
||
```jsonc
|
||
"Telegram": {
|
||
"BotToken": "…", // секрет; пусто = бот не стартует
|
||
"ProxyUrl": "socks5://[user:pass@]host:port", // прокси для запросов к Bot API; пусто = без прокси
|
||
"PublicSiteUrl": "https://dashboard.example.com", // кнопка «🌐 Сайт панели» в меню; пусто = кнопки нет
|
||
"AdminTelegramUserIds": "123456789,987654321" // через запятую
|
||
}
|
||
```
|
||
|
||
Username бота для deepLink (кнопка «Привязать Telegram»/QR, `?start=link_<token>`/`?start=login_<id>`)
|
||
панель получает сама через Bot API (`getMe`) и кэширует на время жизни процесса (`ITelegramBotInfo`,
|
||
`Api/Telegram/TelegramBotInfo.cs`) — отдельного поля конфигурации для него больше нет (было
|
||
`BotUsername`, убрано: опечатка/лишний пробел в env ломали ссылку, а источник истины и так есть в
|
||
самом Telegram). Если `BotToken` пуст или `getMe` не отвечает — `deepLink` в ответах API будет `null`.
|
||
|
||
Переменные окружения — `Telegram__BotToken`, `Telegram__ProxyUrl`, `Telegram__PublicSiteUrl`,
|
||
`Telegram__AdminTelegramUserIds` (см. [`.env.example`](../.env.example)). `AdminTelegramUserIds`
|
||
авторизует админ-кнопки в боте и определяет, кому слать уведомления о запросах активации — **не**
|
||
сидируется в БД и не связан с учёткой сид-админа (`AdminSeed:*`), это независимый список.
|
||
|
||
Сообщения бота **не локализованы** по языку пользователя — все тексты на русском независимо от языка
|
||
интерфейса на сайте (в отличие от веба, где RU/EN переключаются). Синхронизация языка бота с вебом
|
||
не реализована.
|