Implement Telegram bot configuration updates and user messaging enhancements
- Added inline button functionality to the `/configs` command, allowing users to request connection strings for their configurations without displaying them in chat history. - Introduced a constant message for unlinked Telegram accounts to improve user understanding of the linking process. - Updated the handling of configuration messages to include inline buttons for better user interaction and experience.
This commit is contained in:
@@ -2,10 +2,12 @@ using Microsoft.Extensions.Options;
|
|||||||
using PnvPanel.Application.Admin.Activation;
|
using PnvPanel.Application.Admin.Activation;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Configs.GetConfigLink;
|
||||||
using PnvPanel.Application.Configs.GetMyConfigs;
|
using PnvPanel.Application.Configs.GetMyConfigs;
|
||||||
using PnvPanel.Application.Telegram;
|
using PnvPanel.Application.Telegram;
|
||||||
using PnvPanel.Application.Telegram.Bot;
|
using PnvPanel.Application.Telegram.Bot;
|
||||||
using PnvPanel.Domain.Activation;
|
using PnvPanel.Domain.Activation;
|
||||||
|
using PnvPanel.Domain.Configs;
|
||||||
using PnvPanel.Infrastructure.Telegram;
|
using PnvPanel.Infrastructure.Telegram;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Polling;
|
using Telegram.Bot.Polling;
|
||||||
@@ -23,6 +25,11 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
IServiceScopeFactory scopeFactory, IOptions<TelegramOptions> options, ILogger<PnvBotUpdateHandler> logger)
|
IServiceScopeFactory scopeFactory, IOptions<TelegramOptions> options, ILogger<PnvBotUpdateHandler> logger)
|
||||||
: IUpdateHandler
|
: IUpdateHandler
|
||||||
{
|
{
|
||||||
|
// Показывается везде, где боту нужен привязанный аккаунт, а его нет — явно проговариваем оба шага,
|
||||||
|
// иначе новые пользователи не понимают, что сначала нужен обычный аккаунт на сайте.
|
||||||
|
private const string NotLinkedMessage =
|
||||||
|
"Сначала зарегистрируйтесь и войдите на сайте, затем привяжите Telegram: Настройки → «Привязать Telegram».";
|
||||||
|
|
||||||
public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
|
public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var scope = scopeFactory.CreateAsyncScope();
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
@@ -139,6 +146,24 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
await botClient.SendMessage(chatId.Value, text, cancellationToken: cancellationToken);
|
await botClient.SendMessage(chatId.Value, text, cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "cfg":
|
||||||
|
{
|
||||||
|
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||||
|
{
|
||||||
|
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var linkResult = await sender.Send(new GetConfigLinkQuery(requestId), cancellationToken);
|
||||||
|
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
await botClient.SendMessage(
|
||||||
|
chatId.Value,
|
||||||
|
linkResult.IsSuccess ? linkResult.Value.ConnectionString : $"Не удалось получить ссылку: {linkResult.Error.Message}",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,7 +196,7 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
|
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
|
||||||
if (userId is null)
|
if (userId is null)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(chatId, "Сначала привяжите Telegram к аккаунту на сайте.", cancellationToken: cancellationToken);
|
await botClient.SendMessage(chatId, NotLinkedMessage, cancellationToken: cancellationToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,9 +216,7 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
{
|
{
|
||||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await botClient.SendMessage(chatId, NotLinkedMessage, cancellationToken: cancellationToken);
|
||||||
chatId, "Сначала привяжите Telegram к аккаунту на сайте (Настройки → Привязать Telegram).",
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,9 +229,17 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var lines = result.Value.Configs.Select(c =>
|
foreach (var config in result.Value.Configs)
|
||||||
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}");
|
{
|
||||||
await botClient.SendMessage(chatId, "Ваши конфиги:\n" + string.Join('\n', lines), cancellationToken: cancellationToken);
|
var text = $"• {config.Label ?? config.Location} ({config.Protocol}) — {config.Status}";
|
||||||
|
|
||||||
|
// Отозванному конфигу нечего показывать — кнопку не даём.
|
||||||
|
InlineKeyboardMarkup? keyboard = config.Status == ConfigStatus.Revoked
|
||||||
|
? null
|
||||||
|
: new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("🔗 Показать ссылку", $"cfg:link:{config.Id}") });
|
||||||
|
|
||||||
|
await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleUnlinkAsync(
|
private async Task HandleUnlinkAsync(
|
||||||
|
|||||||
+5
-3
@@ -92,11 +92,13 @@
|
|||||||
- Флоу привязки (`LinkTelegramCommand`) + эндпоинт `link-token`/`unlink`.
|
- Флоу привязки (`LinkTelegramCommand`) + эндпоинт `link-token`/`unlink`.
|
||||||
- Passwordless-вход: `login-request` + подтверждение в боте (`ApproveTelegramLoginCommand`) → выпуск JWT; поллинг завершения на фронте (`GET /api/auth/telegram/login-request/{id}`).
|
- Passwordless-вход: `login-request` + подтверждение в боте (`ApproveTelegramLoginCommand`) → выпуск JWT; поллинг завершения на фронте (`GET /api/auth/telegram/login-request/{id}`).
|
||||||
- Команды бота: `/start` (+ `link_<token>`/`login_<requestId>` deep-link payload), «Мои конфиги»
|
- Команды бота: `/start` (+ `link_<token>`/`login_<requestId>` deep-link payload), «Мои конфиги»
|
||||||
(`/configs`, текстовый список, без ссылок/QR), `/unlink`, `/requests`, `/help`.
|
(`/configs` — по сообщению на конфиг, с inline-кнопкой «🔗 Показать ссылку», раскрывающей connection
|
||||||
|
string по запросу через тот же `GetConfigLinkQuery`, что и веб; ссылка не выводится сразу в списке,
|
||||||
|
чтобы не светиться в истории чата без явного действия юзера), `/unlink`, `/requests`, `/help`.
|
||||||
- **Админ в боте**: уведомления о запросах активации + inline «Активировать/Отклонить», `/requests` (по Telegram id из env).
|
- **Админ в боте**: уведомления о запросах активации + inline «Активировать/Отклонить», `/requests` (по Telegram id из env).
|
||||||
- **DM-уведомления юзеру**: активация (`ApproveActivationCommandHandler`), блокировка (`BlockUserCommandHandler`), принудительный отзыв конфига админом (`ForceRevokeConfigCommandHandler`) — если Telegram привязан. Бот — read-only по конфигам.
|
- **DM-уведомления юзеру**: активация (`ApproveActivationCommandHandler`), блокировка (`BlockUserCommandHandler`), принудительный отзыв конфига админом (`ForceRevokeConfigCommandHandler`) — если Telegram привязан. Бот — read-only по конфигам (только просмотр/показ ссылки, без создания/ротации/отзыва).
|
||||||
- **Готово, когда**: юзер привязывает Telegram, входит без пароля, видит конфиги; админ активирует запросы прямо в боте. ✅ Достигнуто.
|
- **Готово, когда**: юзер привязывает Telegram, входит без пароля, видит конфиги; админ активирует запросы прямо в боте. ✅ Достигнуто.
|
||||||
- **Перенесено в backlog** (не реализовано в MVP): восстановление пароля через бота (`/resetpassword` с одноразовой ссылкой) — сейчас сброс пароля только через админа (`ResetUserPasswordCommand`); QR прямо в сообщениях бота.
|
- **Перенесено в backlog** (не реализовано в MVP): восстановление пароля через бота (`/resetpassword` с одноразовой ссылкой) — сейчас сброс пароля только через админа (`ResetUserPasswordCommand`); QR-картинкой в сообщениях бота (пока только текстовая ссылка).
|
||||||
Фронтовые кнопки «Войти через Telegram»/«Привязать Telegram» реализованы в отдельной итерации (см. M0 фронт).
|
Фронтовые кнопки «Войти через Telegram»/«Привязать Telegram» реализованы в отдельной итерации (см. M0 фронт).
|
||||||
|
|
||||||
## M8 — Закалка (hardening) ✅
|
## M8 — Закалка (hardening) ✅
|
||||||
|
|||||||
+14
-9
@@ -6,9 +6,10 @@ Telegram-бот — **второй канал доставки** (presentation-
|
|||||||
|
|
||||||
## Возможности (реализовано)
|
## Возможности (реализовано)
|
||||||
|
|
||||||
1. **Мои конфиги** — `/configs` присылает текстовый список (метка/локация, протокол, статус) —
|
1. **Мои конфиги** — `/configs` присылает по сообщению на конфиг (метка/локация, протокол, статус) с
|
||||||
**без ссылок и QR** в самом боте; за ссылкой/QR пользователь идёт на сайт. Доступно только
|
inline-кнопкой **«🔗 Показать ссылку»**; connection string приходит отдельным сообщением только по
|
||||||
привязанному аккаунту.
|
нажатию (не светится в списке/истории чата без явного действия пользователя). Только текстовая
|
||||||
|
ссылка, без QR-картинки — за QR пользователь идёт на сайт. Доступно только привязанному аккаунту.
|
||||||
2. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: инициируется на сайте,
|
2. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: инициируется на сайте,
|
||||||
подтверждается в боте кнопками «Подтвердить/Отклонить». Требует предварительной привязки Telegram.
|
подтверждается в боте кнопками «Подтвердить/Отклонить». Требует предварительной привязки Telegram.
|
||||||
3. **Админ: обработка запросов активации** — админ (по Telegram id из `Telegram__AdminTelegramUserIds`)
|
3. **Админ: обработка запросов активации** — админ (по Telegram id из `Telegram__AdminTelegramUserIds`)
|
||||||
@@ -19,12 +20,13 @@ Telegram-бот — **второй канал доставки** (presentation-
|
|||||||
5. **Отвязка** — `/unlink`.
|
5. **Отвязка** — `/unlink`.
|
||||||
|
|
||||||
**Не реализовано / backlog:**
|
**Не реализовано / backlog:**
|
||||||
- Ссылки/QR/подписка в самом боте (только текстовый список конфигов).
|
- QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке).
|
||||||
- Отдельная команда `/resetpassword` с одноразовой ссылкой — восстановление пароля сейчас идёт
|
- Отдельная команда `/resetpassword` с одноразовой ссылкой — восстановление пароля сейчас идёт
|
||||||
только через обычный passwordless-вход (`/start login_<n>`) + смену пароля в настройках на сайте.
|
только через обычный passwordless-вход (`/start login_<n>`) + смену пароля в настройках на сайте.
|
||||||
- Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет.
|
- Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет.
|
||||||
- Регистрация нового аккаунта из бота (только привязка существующего).
|
- Регистрация нового аккаунта из бота (только привязка существующего).
|
||||||
- Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам.
|
- Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам (только
|
||||||
|
просмотр списка и показ существующей ссылки по кнопке).
|
||||||
|
|
||||||
## Размещение в архитектуре
|
## Размещение в архитектуре
|
||||||
|
|
||||||
@@ -97,9 +99,12 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
|
|
||||||
1. Привязанный пользователь: `/configs`.
|
1. Привязанный пользователь: `/configs`.
|
||||||
2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`.
|
2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`.
|
||||||
3. Ответ — обычное текстовое сообщение, по строке на конфиг:
|
3. Ответ — отдельное сообщение на каждый конфиг: `• {Label ?? Location} ({Protocol}) — {Status}` +
|
||||||
`• {Label ?? Location} ({Protocol}) — {Status}`. Если конфигов нет — «У вас пока нет конфигов.»
|
inline-кнопка «🔗 Показать ссылку» (кроме отозванных — там кнопки нет). Если конфигов нет —
|
||||||
**Ссылок, QR и кнопок здесь нет** — за подключением пользователь идёт на сайт.
|
«У вас пока нет конфигов.»
|
||||||
|
4. Нажатие кнопки → callback `cfg:link:{configId}` → бот вызывает `GetConfigLinkQuery` (тот же, что
|
||||||
|
эндпоинт `/api/configs/{id}/link`) от текущего пользователя и присылает connection string отдельным
|
||||||
|
сообщением. Ссылка не дублируется никуда до явного нажатия. QR-картинки нет — только текст.
|
||||||
|
|
||||||
## Флоу 4 — Обработка активации админом в боте
|
## Флоу 4 — Обработка активации админом в боте
|
||||||
|
|
||||||
@@ -125,7 +130,7 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
| `/start` | Приветствие + справка по командам | нет |
|
| `/start` | Приветствие + справка по командам | нет |
|
||||||
| `/start link_<token>` | Привязка аккаунта по токену | нет |
|
| `/start link_<token>` | Привязка аккаунта по токену | нет |
|
||||||
| `/start login_<requestId>` | Подтверждение passwordless-входа (deep-link с сайта) | да |
|
| `/start login_<requestId>` | Подтверждение passwordless-входа (deep-link с сайта) | да |
|
||||||
| `/configs` | Текстовый список конфигов | да |
|
| `/configs` | Список конфигов с кнопкой «Показать ссылку» на каждом | да |
|
||||||
| `/unlink` | Отвязать Telegram от аккаунта | да |
|
| `/unlink` | Отвязать Telegram от аккаунта | да |
|
||||||
| `/help` | Справка (то же сообщение, что `/start`) | нет |
|
| `/help` | Справка (то же сообщение, что `/start`) | нет |
|
||||||
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
|
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
|
||||||
|
|||||||
Reference in New Issue
Block a user