Implement support ticket system with role request and bug report functionalities
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s

- 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.
This commit is contained in:
Leonid Pershin
2026-07-14 06:49:05 +03:00
parent 14b64a3140
commit b5630b2685
98 changed files with 4463 additions and 6 deletions
+53 -1
View File
@@ -122,6 +122,56 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
| GET | `/api/activation/status` | user | — | `{ isActivated, pendingRequest: { id, comment, createdAt } \| null }` |
| POST | `/api/activation/request` | user | `{ comment? }` | `{ id, comment, createdAt }` |
## Support (пользователь)
Группа `/api/support`, `RequireAuthorization()` + `IRequiresActivation` (кроме `GET /attachments/{id}`,
который тоже требует активации, но не привязан к типу тикета). Создание баг-репорта и добавление
комментария — `multipart/form-data` (вложения), остальное — JSON.
| Метод | Путь | Тело запроса | Тело ответа |
| ----- | ----------------------------------------- | ---------------------------------------------------------------------------- | ------------- |
| GET | `/api/support/roles` | — | `RoleDto[]` (без `admin`) — для выбора существующей роли в заявке |
| GET | `/api/support/tickets` | query: `type?, status?, page=1, pageSize=20` | `PagedList<TicketSummaryDto>` (только свои) |
| GET | `/api/support/tickets/{id}` | — | `TicketDetailDto` (404, если не свой) |
| POST | `/api/support/tickets/bug-reports` | multipart: `message` + `files[]` (до 5, изображения до 5 МБ) | `TicketDetailDto` |
| POST | `/api/support/tickets/role-requests` | `{ existingRoleId? \| (newRoleName, newRoleMaxConfigs, newRoleMaxIpLimit), justification }` | `TicketDetailDto` |
| POST | `/api/support/tickets/{id}/comments` | multipart: `body` + `files[]` | `TicketCommentDto` |
| POST | `/api/support/tickets/{id}/reopen` | — | `204 No Content` (только владелец, только из `Resolved`) |
| GET | `/api/support/attachments/{id}` | — | бинарный поток с `Content-Type` вложения |
`TicketSummaryDto`: `{ id, userId, userName, type, status, createdAt, lastActivityAt }` — один DTO для
своего и админского списков. `TicketDetailDto` добавляет `requestedRoleId, requestedRoleName,
proposedRoleName, proposedMaxConfigs, proposedMaxIpLimit, comments: TicketCommentDto[]`.
`TicketCommentDto`: `{ id, authorId, authorName, body, createdAt, attachments: TicketAttachmentDto[] }`.
Ровно одна из двух заявок на роль: либо `existingRoleId` (роль `admin` запрещена — `403
Support.CannotRequestAdminRole`), либо все три поля новой роли. Заявка при существующем открытом
запросе на роль → `409 Support.RoleRequestAlreadyPending`. `POST …/comments` на `Closed`-тикете →
`409 Support.TicketClosed`. Вложения отдаются не статикой — `<img src>` не может передать
`Authorization`-заголовок, фронт качает их как `Blob` через `fetch` и рендерит `Object URL`.
## Admin — Support
Группа `/api/admin/support`, `RequireAuthorization(RoleNames.Admin)` (активация не проверяется — сеяный
админ активирован всегда).
| Метод | Путь | Тело запроса | Тело ответа |
| ----- | ------------------------------------------------ | ----------------------------------- | ------------- |
| GET | `/api/admin/support/tickets` | query: `type?, status?, page, pageSize` | `PagedList<TicketSummaryDto>` (все пользователи) |
| GET | `/api/admin/support/tickets/{id}` | — | `TicketDetailDto` |
| POST | `/api/admin/support/tickets/{id}/comments` | multipart: `body` + `files[]` | `TicketCommentDto` |
| POST | `/api/admin/support/tickets/{id}/resolve` | — | `204 No Content` (любой тип, только из `Open`) |
| POST | `/api/admin/support/tickets/{id}/close` | — | `204 No Content` (любой тип, финал) |
| POST | `/api/admin/support/tickets/{id}/approve` | — | `204 No Content` (только `RoleRequest`/`Open`; создаёt/назначает роль) |
| POST | `/api/admin/support/tickets/{id}/reject` | `{ reason? }` | `204 No Content` (только `RoleRequest`; `reason` уходит комментарием) |
`approve`/`reject` — единственный способ решить заявку на роль (нельзя одобрить через `resolve`).
При одобрении: если заявка на существующую роль — сразу `ChangeUserRoleCommand`-эквивалент; если на
новую — сперва создаётся `AppRole` (`IRoleService.CreateRoleAsync`), затем назначается. То же самое
администратор может сделать **из Telegram, не заходя на сайт** — инлайн-кнопки на уведомлении о
заявке (см. [telegram-bot.md](telegram-bot.md)); для баг-репортов в Telegram только кнопка-ссылка
на `/admin/support/{id}` — переписка и вложения только на сайте.
## Admin — Activation, Roles
| Метод | Путь | Роль | Тело запроса | Тело ответа |
@@ -211,6 +261,8 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи
| `activationRequested` | `{ requestId, userId, userName, comment, createdAt }` | `admins` |
| `userActivated` | `{ userId }` | владельцу |
| `newsPublished` | `{ id, title, createdAt }` | все (broadcast) |
| `ticketCreated` | `{ ticketId, userId, userName, type }` | `admins` |
| `ticketUpdated` | `{ ticketId }` | владельцу |
### Client → Server
Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по
@@ -224,7 +276,7 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи
| 401 | Нет/просрочен/невалиден access-токен |
| 403 | Нет прав по роли, либо `Auth.NotActivated` |
| 404 | Ресурс не найден |
| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации |
| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации, `Support.RoleRequestAlreadyPending`, `Support.TicketClosed` |
| 422 | Прочие управляемые ошибки, не подошедшие под коды выше |
| 429 | Rate limit (`/api/auth/*`, `/api/auth/telegram/*`, `/sub/{token}`) |
| 500 | Необработанное исключение (перехватывается `UseExceptionHandler()`, тело без деталей) |
+12 -5
View File
@@ -68,15 +68,16 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
- **Commands / Queries** + их **Handlers** (`ICommandHandler<,>` / `IQueryHandler<,>` — свои интерфейсы),
организованы по фичам (`Auth/Login/`, `Configs/Create/`, `Admin/Nodes/`, ...).
- **Ports (интерфейсы)**: `IAppDbContext`, `IXuiPanelGateway`, `ICurrentUser`, `IIdentityService`,
`ISecretProtector`, `IRealtimeNotifier`, `ITelegramNotifier`, `IRoleService`.
`ISecretProtector`, `IRealtimeNotifier`, `ITelegramNotifier`, `IRoleService`, `IFileStorage`
(вложения тикетов поддержки — диск в контейнере, см. `Infrastructure/Storage/DiskFileStorage`).
- **Validators**: FluentValidation на команды, где есть что проверять помимо типов (не на все — см.
[backend-conventions.md](backend-conventions.md)).
- **DTO**: плоские `record`, конвертация из сущностей — статический метод `FromDomain(...)` на самом
DTO, без маппера (Mapster/AutoMapper).
- **Pipeline behaviors** (порядок: Logging → Validation → RequireActivation → UnitOfWork):
`LoggingBehavior`, `ValidationBehavior`, `RequireActivationBehavior` (403 `Auth.NotActivated` для
запросов с маркером `IRequiresActivation` — конфиги, новости, каталог приложений), `UnitOfWorkBehavior`
(транзакция + `SaveChangesAsync` на команду). Отдельного `AuthorizationBehavior` для ролей нет —
запросов с маркером `IRequiresActivation` — конфиги, новости, каталог приложений, тикеты поддержки),
`UnitOfWorkBehavior` (транзакция + `SaveChangesAsync` на команду). Отдельного `AuthorizationBehavior` для ролей нет —
роль проверяется через `RequireAuthorization()`/`RequireRole(...)` на эндпоинте либо явной проверкой
в начале хендлера (например, «инбаунд доступен роли пользователя»).
- **Result<T>**: явная модель успеха/ошибки (`Result`/`Result<T>`, `Error` с `ErrorType`) вместо
@@ -97,6 +98,8 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
- **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest,
ASP.NET Core Data Protection, key-ring на томе `dp_keys`).
- **Telegram**: `TelegramNotifier : ITelegramNotifier` — отправка DM-уведомлений через `ITelegramBotClient`.
- **Storage**: `DiskFileStorage : IFileStorage` — вложения тикетов поддержки, файлы на диске под
GUID-именем (`FileStorage:RootPath`, том `ticket_uploads` в docker-compose, как `dp_keys`).
> **SignalR-пуш физически лежит в `PnvPanel.Api/Hubs/`, не в `Infrastructure`.**
> `SignalRRealtimeNotifier : IRealtimeNotifier` нужен `IHubContext<PanelHub>`, а сам `PanelHub`
@@ -106,10 +109,14 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
### 4. `PnvPanel.Api` (Presentation)
Композиционный корень и транспорт.
- **Minimal API**-эндпоинты, сгруппированные по фичам — 12 файлов в `Endpoints/`
- **Minimal API**-эндпоинты, сгруппированные по фичам — файлы в `Endpoints/`
(`AuthEndpoints`, `ActivationEndpoints`, `ConfigEndpoints`, `AppEndpoints`, `SubscriptionEndpoints`,
`TelegramEndpoints`, `AdminUserEndpoints`, `AdminAppEndpoints`, `AdminStatsEndpoints`, `NodeEndpoints`,
`InboundEndpoints`, `RoleEndpoints`); полный список маршрутов — [api-design.md](api-design.md).
`InboundEndpoints`, `RoleEndpoints`, `SupportEndpoints`, `AdminSupportEndpoints`); полный список
маршрутов — [api-design.md](api-design.md). `SupportEndpoints`/`AdminSupportEndpoints` — первые в
проекте с `multipart/form-data` (`[FromForm]` + `IFormFileCollection`, `.DisableAntiforgery()`
антифоржери-мидлварь в пайплайне не подключена, но ASP.NET Core минимал-API требует явно снять
требование для form-эндпоинтов).
Каждый эндпоинт аннотирован `.Produces<T>()`, чтобы OpenAPI-схема полностью описывала тело ответа
(нужно для `pnpm gen:api` на фронте).
- **SignalR Hubs**: `PanelHub` (`Hubs/`).
+77
View File
@@ -22,6 +22,10 @@ VpnConfig ─*─ TrafficSample (история трафика; пишетс
AuditLog (append-only журнал действий; ссылается на ActorId/TargetId)
ClientApp (каталог приложений-клиентов; группируется по OperatingSystem)
NewsPost (лента новостей; публикуется админом, видна всем аутентифицированным пользователям)
AppUser
└─0..*─ SupportTicket (баг-репорт/предложение либо заявка на роль)
└─1───*─ TicketComment (переписка; первое сообщение = описание/обоснование)
└─0..*─ TicketAttachment (изображения, диск-хранилище)
```
## Сущности
@@ -272,6 +276,71 @@ UI **настойчиво напоминает** привязать его (ед
Переходы: `Pending → Approved/Rejected/Expired`; `Approved → Consumed` (после выпуска JWT сайту).
После `Consumed`/`Expired` — не переиспользуется.
### SupportTicket — обращение в поддержку
Два вида: `BugReport` (свободная форма, с вложениями) и `RoleRequest` (запрос существующей роли —
кроме `admin` — либо параметров новой). Текст/обоснование не хранится отдельным полем — это первое
сообщение в переписке (`TicketComment`), созданное вместе с тикетом в одной операции.
| Поле | Тип | Заметки |
| ------------------- | ----------------- | ---------------------------------------------------------------- |
| `Id` | `Guid` | PK |
| `UserId` | `Guid` | FK → AppUser (автор) |
| `Type` | `TicketType` | `BugReport` / `RoleRequest` |
| `Status` | `TicketStatus` | `Open` / `Resolved` / `Closed` |
| `RequestedRoleId` | `Guid?` | Заполнено для `RoleRequest` при выборе существующей роли |
| `ProposedRoleName` | `string?` | Заполнено для `RoleRequest` при запросе новой роли |
| `ProposedMaxConfigs`| `int?` | Параметры новой роли (см. `AppRole.MaxConfigs`) |
| `ProposedMaxIpLimit`| `int?` | Параметры новой роли (см. `AppRole.MaxIpLimit`) |
| `CreatedAt` | `DateTimeOffset` | |
Инварианты и переходы (`backend/src/PnvPanel.Domain/Support/SupportTicket.cs`): `RequestedRoleId`
и `Proposed*` никогда не заполнены одновременно — гарантируется отдельными фабриками
(`CreateRoleRequestForExistingRole`/`CreateRoleRequestForNewRole`), а не runtime-проверкой.
- `Resolve()` — только из `Open`. Для `RoleRequest` одобрение — оркестрация в Application
(`ApproveRoleRequestCommandHandler`): при новой роли сначала `IRoleService.CreateRoleAsync`, затем
в любом случае `ChangeUserRoleAsync` пользователю, и только потом `ticket.Resolve()`.
- `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `RoleRequest` — отклонение.
- `Reopen()` — только из `Resolved` (владелец тикета); `Closed` не переоткрывается.
- Одновременно не более одной **открытой** заявки на роль (`Type == RoleRequest && Status == Open`)
на пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`.
Баг-репорты такого ограничения не имеют.
- Доступ — только активированному пользователю (`IRequiresActivation`, как и у конфигов/новостей);
админские действия (resolve/close/approve/reject) идут по отдельным `/api/admin/support/*` с
ролевой проверкой, без завязки на активацию.
### TicketComment — сообщение в переписке
Плоская сущность (не навигационная коллекция на `SupportTicket` — конвенция проекта, см.
`TrafficSample`), одна на любое сообщение (включая первое, созданное вместе с тикетом).
| Поле | Тип | Заметки |
| ----------- | ---------------- | --------------------------------------------------------- |
| `Id` | `Guid` | PK |
| `TicketId` | `Guid` | FK → SupportTicket |
| `AuthorId` | `Guid` | FK → AppUser (владелец тикета либо админ) |
| `Body` | `string` | |
| `CreatedAt` | `DateTimeOffset` | |
Комментарий запрещён на `Closed`-тикете; на `Open`/`Resolved` — можно (для `Resolved` это не
переоткрывает тикет автоматически, переоткрытие — отдельное явное действие пользователя `Reopen()`).
### TicketAttachment — вложение (изображение)
Хранится на диске контейнера (`IFileStorage`/`DiskFileStorage`, volume `ticket_uploads` в
docker-compose) — первая в проекте функциональность загрузки файлов. Вайтлист
`image/jpeg|png|webp|gif`, до 5 МБ на файл, до 5 файлов на сообщение.
| Поле | Тип | Заметки |
| ---------------- | ---------------- | ------------------------------------------------------------------ |
| `Id` | `Guid` | PK |
| `CommentId` | `Guid` | FK → TicketComment |
| `FileName` | `string` | Оригинальное имя — только для отображения, не участвует в пути на диске |
| `StoredFileName` | `string` | Серверное GUID-имя на диске (не доверяем пользовательскому вводу) |
| `ContentType` | `string` | |
| `SizeBytes` | `long` | |
| `CreatedAt` | `DateTimeOffset` | |
Отдаётся авторизованным эндпоинтом (`GET /api/support/attachments/{id}`, проверка владения тикетом
или роли admin), не статикой — вложения могут быть чувствительными.
## Value Objects
- **NodeCredentials** (`Nodes/NodeCredentials.cs`) — `Username` + `ProtectedPassword` (шифротекст,
@@ -291,6 +360,8 @@ enum TelegramLoginStatus { Pending, Approved, Rejected, Expired, Consumed }
enum ActivationStatus { Pending, Approved, Rejected }
enum AuditSource { Web, Telegram, System }
enum OsPlatform { IOS, Android, Windows, MacOS, Linux }
enum TicketType { BugReport, RoleRequest }
enum TicketStatus { Open, Resolved, Closed }
```
## Уведомления и аудит (без диспетчера доменных событий)
@@ -316,6 +387,12 @@ enum OsPlatform { IOS, Android, Windows, MacOS, Linux }
| `PublishInboundCommandHandler` | `AuditLog` (`InboundPublished`/`InboundUnpublished`) |
| `NodeHealthCheckService` (фон) | Обновляет `NodeStatus`; realtime `nodeStatusChanged` группе `admins` |
| `TrafficSyncService` (фон) | `UpdateTraffic(...)`; realtime `configTrafficUpdated` владельцу |
| `CreateBugReportTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — превью текста + кнопка-ссылка на сайт |
| `CreateRoleRequestTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — инлайн-кнопки «Одобрить/Отклонить» |
| `AddTicketCommentCommandHandler` | Realtime `ticketUpdated` владельцу, только если комментирует не он сам |
| `ApproveRoleRequestCommandHandler` | Создаёт роль (если новая) + назначает пользователю; `AuditLog` (`RoleRequestApproved`); Telegram-DM владельцу |
| `RejectRoleRequestCommandHandler` | `AuditLog` (`RoleRequestRejected`); Telegram-DM владельцу |
| `ResolveTicketCommandHandler` / `CloseTicketCommandHandler` | `AuditLog` (`TicketResolved`/`TicketClosed`); realtime `ticketUpdated` владельцу |
SignalR-события и группы — см. [architecture.md](architecture.md#realtime-signalr) и
[api-design.md](api-design.md#signalr--hub-hubspanel).
+2
View File
@@ -96,6 +96,8 @@
| Лимит устройств (`limitIp`) | Квота роли (`AppRole.MaxIpLimit`; -1 = без лимита), применяется только к новым клиентам в 3x-ui |
| Самоудаление аккаунта | Отзыв всех активных конфигов в 3x-ui + удаление `AppUser` |
| Удаление пользователя админом | `DELETE /api/admin/users/{id}` — отзыв всех конфигов в 3x-ui + удаление `AppUser`; себя удалить нельзя |
| Вложения тикетов поддержки | Диск в контейнере (`IFileStorage`/`DiskFileStorage`, volume `ticket_uploads`) — не S3, GUID-имена файлов |
| Заявки на роль из бота | Одобрение/отклонение полностью в Telegram (`rrq:*`); баг-репорты — только ссылка на сайт |
| Версионирование API | Без версий (`/api` без `v1`) |
| Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` |
| Тема сайта | Светлая + тёмная (+ системная); выбор в localStorage |
+37
View File
@@ -33,6 +33,12 @@ Telegram-бот — **второй канал доставки** (presentation-
Зарегистрироваться»; плюс кнопка «🌐 Сайт панели» со ссылкой на сайт, если задан `Telegram__PublicSiteUrl`
(пусто — кнопки нет). Слэш-команды `/configs`/`/unlink` продолжают работать как раньше — кнопки лишь
вызывают те же обработчики через callback (`menu:configs`/`menu:unlink`/`menu:back`).
8. **Админ: обработка заявок на роль поддержки** — при новой заявке (`SupportTicket.Type ==
RoleRequest`) админ получает сообщение с описанием (существующая роль либо параметры новой) и
обоснованием, жмёт «✅ Одобрить / ❌ Отклонить» **прямо в Telegram, без захода на сайт** — одобрение
создаёт роль (если новая) и назначает её пользователю той же командой, что и на сайте. Баг-репорты/
предложения — только уведомление с кнопкой-ссылкой на сайт, без инлайн-действий (переписка и
вложения удобнее там).
**Не реализовано:**
- QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке).
@@ -41,6 +47,8 @@ Telegram-бот — **второй канал доставки** (presentation-
- Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет.
- Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам (только
просмотр списка и показ существующей ссылки по кнопке).
- Баг-репорты/предложения тикетов поддержки **не решаются из бота** (только уведомление-ссылка) —
ответы, вложения, resolve/close только на сайте.
## Размещение в архитектуре
@@ -162,6 +170,33 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
`/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 «✅ Ваша заявка на роль одобрена.» / «❌ Ваша заявка на
роль отклонена.».
## Команды и клавиатуры
| Команда / кнопка | Действие | Требует привязки |
@@ -177,6 +212,8 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
| «📝 Зарегистрироваться» (`reg:new`) | Регистрация нового аккаунта прямо из бота (Флоу 3) | нет (нужно, чтобы **не** был привязан) |
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
| «✅ Одобрить»/«❌ Отклонить» (`rrq:*`) | (admin) решение по заявке на роль — создаёт/назначает роль | админ по env |
| «🌐 Открыть на сайте» | Ссылка на баг-репорт на сайте (только если задан `Telegram__PublicSiteUrl`) | админ по env |
Главное меню (`/start`/`/help`) — см. пункт 7 в «Возможности» выше.