Enhance Telegram bot functionality and configuration options
- Added new inline button features to the `/configs` command, allowing users to view their configurations in a single message with active links and a back button. - Implemented a menu for unlinking Telegram accounts, providing a clearer user experience when managing account connections. - Updated the `.env.example` file to include a new `Telegram__PublicSiteUrl` setting, enabling a button for accessing the panel's website directly from the bot. - Enhanced documentation to reflect the new features and configuration options available in the Telegram bot.
This commit is contained in:
@@ -47,6 +47,8 @@ Telegram__AdminTelegramUserIds=123456789
|
|||||||
# Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port) — на случай, если Telegram
|
# Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port) — на случай, если Telegram
|
||||||
# заблокирован напрямую с сети сервера. Пусто (по умолчанию) — без прокси, прямое подключение.
|
# заблокирован напрямую с сети сервера. Пусто (по умолчанию) — без прокси, прямое подключение.
|
||||||
# Telegram__ProxyUrl=socks5://127.0.0.1:39372
|
# Telegram__ProxyUrl=socks5://127.0.0.1:39372
|
||||||
|
# Ссылка на сайт панели — кнопка «🌐 Сайт панели» в главном меню бота. Пусто — кнопки не будет.
|
||||||
|
# Telegram__PublicSiteUrl=https://dashboard.example.com
|
||||||
|
|
||||||
# ── ASP.NET Core ──────────────────────────────────────────────────────────
|
# ── ASP.NET Core ──────────────────────────────────────────────────────────
|
||||||
ASPNETCORE_ENVIRONMENT=Production
|
ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
|||||||
@@ -110,6 +110,68 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data == "menu:configs")
|
||||||
|
{
|
||||||
|
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||||
|
{
|
||||||
|
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
var (configsText, configsKeyboard) = await BuildConfigsMenuAsync(services, cancellationToken);
|
||||||
|
if (callback.Message is not null)
|
||||||
|
await botClient.EditMessageText(chatId.Value, callback.Message.Id, configsText, replyMarkup: configsKeyboard, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data == "menu:back")
|
||||||
|
{
|
||||||
|
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
|
||||||
|
if (callback.Message is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var identityService = services.GetRequiredService<IIdentityService>();
|
||||||
|
var linkedUserId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
|
||||||
|
var (menuText, menuKeyboard) = BuildMainMenu(isLinked: linkedUserId is not null);
|
||||||
|
await botClient.EditMessageText(chatId.Value, callback.Message.Id, menuText, replyMarkup: menuKeyboard, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data == "menu:unlink")
|
||||||
|
{
|
||||||
|
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||||
|
{
|
||||||
|
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var unlinkSender = services.GetRequiredService<ISender>();
|
||||||
|
var unlinkResult = await unlinkSender.Send(new UnlinkTelegramCommand(), cancellationToken);
|
||||||
|
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (callback.Message is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!unlinkResult.IsSuccess)
|
||||||
|
{
|
||||||
|
await botClient.EditMessageText(
|
||||||
|
chatId.Value, callback.Message.Id, $"❌ Ошибка: {unlinkResult.Error.Message}",
|
||||||
|
replyMarkup: BackToMenuKeyboard(), cancellationToken: cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (unlinkedText, unlinkedKeyboard) = BuildMainMenu(isLinked: false);
|
||||||
|
await botClient.EditMessageText(
|
||||||
|
chatId.Value, callback.Message.Id, "✅ Telegram отвязан от аккаунта.\n\n" + unlinkedText,
|
||||||
|
replyMarkup: unlinkedKeyboard, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var parts = data.Split(':');
|
var parts = data.Split(':');
|
||||||
if (parts.Length != 3 || !Guid.TryParse(parts[2], out var requestId))
|
if (parts.Length != 3 || !Guid.TryParse(parts[2], out var requestId))
|
||||||
return;
|
return;
|
||||||
@@ -171,13 +233,21 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (callback.Message is null)
|
||||||
|
break;
|
||||||
|
|
||||||
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
|
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
|
||||||
// блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением.
|
// блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением.
|
||||||
var originalText = callback.Message?.Text ?? "";
|
// Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте.
|
||||||
var text = $"{Escape(originalText)}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>";
|
var text = $"{Escape(callback.Message.Text ?? "")}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>";
|
||||||
|
var remainingRows = (callback.Message.ReplyMarkup?.InlineKeyboard ?? [])
|
||||||
|
.Where(row => row.All(b => b.CallbackData != data))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
if (callback.Message is not null)
|
await botClient.EditMessageText(
|
||||||
await botClient.EditMessageText(chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
|
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: remainingRows.Length > 0 ? new InlineKeyboardMarkup(remainingRows) : null,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -241,28 +311,38 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (text, keyboard) = await BuildConfigsMenuAsync(services, cancellationToken);
|
||||||
|
await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Список конфигов одним сообщением: строка на конфиг + кнопка «🔗 {Label}» на каждый
|
||||||
|
/// не отозванный, плюс «🔙 В меню» внизу.</summary>
|
||||||
|
private static async Task<(string Text, InlineKeyboardMarkup Keyboard)> BuildConfigsMenuAsync(
|
||||||
|
IServiceProvider services, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
var sender = services.GetRequiredService<ISender>();
|
var sender = services.GetRequiredService<ISender>();
|
||||||
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
|
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
|
||||||
|
|
||||||
if (!result.IsSuccess || result.Value.Configs.Count == 0)
|
if (!result.IsSuccess || result.Value.Configs.Count == 0)
|
||||||
{
|
return ("У вас пока нет конфигов.", BackToMenuKeyboard());
|
||||||
await botClient.SendMessage(chatId, "У вас пока нет конфигов.", cancellationToken: cancellationToken);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var config in result.Value.Configs)
|
var text = "Ваши конфиги:\n" + string.Join('\n', result.Value.Configs.Select(c =>
|
||||||
{
|
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"));
|
||||||
var text = $"• {config.Label ?? config.Location} ({config.Protocol}) — {config.Status}";
|
|
||||||
|
|
||||||
// Отозванному конфигу нечего показывать — кнопку не даём.
|
// Отозванному конфигу нечего показывать — кнопку не даём.
|
||||||
InlineKeyboardMarkup? keyboard = config.Status == ConfigStatus.Revoked
|
var rows = result.Value.Configs
|
||||||
? null
|
.Where(c => c.Status != ConfigStatus.Revoked)
|
||||||
: new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("🔗 Показать ссылку", $"cfg:link:{config.Id}") });
|
.Select(c => new[] { InlineKeyboardButton.WithCallbackData($"🔗 {c.Label ?? c.Location}", $"cfg:link:{c.Id}") })
|
||||||
|
.Append(BackToMenuRow())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
return (text, new InlineKeyboardMarkup(rows));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static InlineKeyboardButton[] BackToMenuRow() => new[] { InlineKeyboardButton.WithCallbackData("🔙 В меню", "menu:back") };
|
||||||
|
|
||||||
|
private static InlineKeyboardMarkup BackToMenuKeyboard() => new(new[] { BackToMenuRow() });
|
||||||
|
|
||||||
private async Task HandleUnlinkAsync(
|
private async Task HandleUnlinkAsync(
|
||||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
|
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -310,26 +390,40 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task SendWelcomeAsync(
|
private async Task SendWelcomeAsync(
|
||||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
|
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
const string text = "Привет! Это бот PnvPanel.\n\n"
|
|
||||||
+ "/configs — мои конфиги\n"
|
|
||||||
+ "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.\n"
|
|
||||||
+ "/unlink — отвязать Telegram\n"
|
|
||||||
+ "/help — эта справка";
|
|
||||||
|
|
||||||
var identityService = services.GetRequiredService<IIdentityService>();
|
var identityService = services.GetRequiredService<IIdentityService>();
|
||||||
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
|
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
|
||||||
|
|
||||||
// Уже привязанным аккаунту предлагать регистрацию незачем.
|
var (text, keyboard) = BuildMainMenu(isLinked: userId is not null);
|
||||||
var keyboard = userId is null
|
|
||||||
? new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") })
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Привязанному аккаунту — кнопки-действия вместо текстовых команд; непривязанному —
|
||||||
|
/// только регистрация (остальное ему всё равно недоступно). Кнопка на сайт — если задан PublicSiteUrl.</summary>
|
||||||
|
private (string Text, InlineKeyboardMarkup Keyboard) BuildMainMenu(bool isLinked)
|
||||||
|
{
|
||||||
|
const string text = "Привет! Это бот PnvPanel.\n\n"
|
||||||
|
+ "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.";
|
||||||
|
|
||||||
|
var rows = new List<InlineKeyboardButton[]>();
|
||||||
|
if (isLinked)
|
||||||
|
{
|
||||||
|
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") });
|
||||||
|
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("🔓 Отвязать Telegram", "menu:unlink") });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||||||
|
rows.Add(new[] { InlineKeyboardButton.WithUrl("🌐 Сайт панели", options.Value.PublicSiteUrl) });
|
||||||
|
|
||||||
|
return (text, new InlineKeyboardMarkup(rows));
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task HandleRegisterCallbackAsync(
|
private static async Task HandleRegisterCallbackAsync(
|
||||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string callbackId,
|
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string callbackId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ public sealed class TelegramOptions
|
|||||||
public const string SectionName = "Telegram";
|
public const string SectionName = "Telegram";
|
||||||
|
|
||||||
public string? BotToken { get; init; }
|
public string? BotToken { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Ссылка на сайт панели — кнопка «🌐 Сайт панели» в главном меню бота. Пусто — кнопки нет.</summary>
|
||||||
public string PublicSiteUrl { get; init; } = string.Empty;
|
public string PublicSiteUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port). Пусто — без прокси.</summary>
|
/// <summary>Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port). Пусто — без прокси.</summary>
|
||||||
|
|||||||
+38
-23
@@ -6,10 +6,13 @@ Telegram-бот — **второй канал доставки** (presentation-
|
|||||||
|
|
||||||
## Возможности (реализовано)
|
## Возможности (реализовано)
|
||||||
|
|
||||||
1. **Мои конфиги** — `/configs` присылает по сообщению на конфиг (метка/локация, протокол, статус) с
|
1. **Мои конфиги** — `/configs` (или кнопка «📋 Мои конфиги» из главного меню) присылает **одно**
|
||||||
inline-кнопкой **«🔗 Показать ссылку»**; connection string приходит отдельным сообщением только по
|
сообщение со списком (метка/локация, протокол, статус) и по кнопке `🔗 {Label}` на каждый активный
|
||||||
нажатию (не светится в списке/истории чата без явного действия пользователя). Только текстовая
|
конфиг + кнопкой **«🔙 В меню»** внизу. Нажатие на конфиг **редактирует то же сообщение**: дописывает
|
||||||
ссылка, без QR-картинки — за QR пользователь идёт на сайт. Доступно только привязанному аккаунту.
|
connection string моноширинным блоком (тап = копирование целиком) и убирает именно эту кнопку —
|
||||||
|
остальные конфиги и «В меню» остаются на месте. Ничего не светится без явного нажатия, отдельных
|
||||||
|
сообщений не плодится. Только текстовая ссылка, без QR-картинки — за QR пользователь идёт на сайт.
|
||||||
|
Доступно только привязанному аккаунту.
|
||||||
2. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: инициируется на сайте,
|
2. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: инициируется на сайте,
|
||||||
подтверждается в боте кнопками «Подтвердить/Отклонить». Требует предварительной привязки Telegram.
|
подтверждается в боте кнопками «Подтвердить/Отклонить». Требует предварительной привязки Telegram.
|
||||||
3. **Админ: обработка запросов активации** — админ (по Telegram id из `Telegram__AdminTelegramUserIds`)
|
3. **Админ: обработка запросов активации** — админ (по Telegram id из `Telegram__AdminTelegramUserIds`)
|
||||||
@@ -17,13 +20,19 @@ Telegram-бот — **второй канал доставки** (presentation-
|
|||||||
«✅ Активировать / ❌ Отклонить» прямо в сообщении. `/requests` показывает все ожидающие запросы по требованию.
|
«✅ Активировать / ❌ Отклонить» прямо в сообщении. `/requests` показывает все ожидающие запросы по требованию.
|
||||||
4. **DM-уведомления пользователю** (если Telegram привязан): активация аккаунта, блокировка,
|
4. **DM-уведомления пользователю** (если Telegram привязан): активация аккаунта, блокировка,
|
||||||
принудительный отзыв конфига админом.
|
принудительный отзыв конфига админом.
|
||||||
5. **Отвязка** — `/unlink`.
|
5. **Отвязка** — `/unlink` или кнопка «🔓 Отвязать Telegram» из главного меню (редактирует то же
|
||||||
|
сообщение в подтверждение + меню для непривязанного состояния).
|
||||||
6. **Регистрация прямо из бота** — кнопка «📝 Зарегистрироваться» показывается там, где боту нужен
|
6. **Регистрация прямо из бота** — кнопка «📝 Зарегистрироваться» показывается там, где боту нужен
|
||||||
привязанный аккаунт, а Telegram ещё не привязан (`/start`, `/help`, `/configs`, запрос passwordless-
|
привязанный аккаунт, а Telegram ещё не привязан (`/start`, `/help`, `/configs`, запрос passwordless-
|
||||||
входа). Логин — `@username` из Telegram; если его нет или он уже занят на сайте — используется
|
входа). Логин — `@username` из Telegram; если его нет или он уже занят на сайте — используется
|
||||||
Telegram id (гарантированно уникален). Пароль генерируется и присылается в чат один раз — сохраните
|
Telegram id (гарантированно уникален). Пароль генерируется и присылается в чат один раз — сохраните
|
||||||
его сразу, при желании логин и пароль можно сменить в Настройках на сайте. Новый аккаунт получает
|
его сразу, при желании логин и пароль можно сменить в Настройках на сайте. Новый аккаунт получает
|
||||||
роль `user` и `IsActivated = false` — активация нужна как для обычной регистрации на сайте.
|
роль `user` и `IsActivated = false` — активация нужна как для обычной регистрации на сайте.
|
||||||
|
7. **Главное меню** — `/start`/`/help` показывают одно сообщение с кнопками вместо текстового списка
|
||||||
|
команд: привязанному аккаунту — «📋 Мои конфиги» / «🔓 Отвязать Telegram», непривязанному — «📝
|
||||||
|
Зарегистрироваться»; плюс кнопка «🌐 Сайт панели» со ссылкой на сайт, если задан `Telegram__PublicSiteUrl`
|
||||||
|
(пусто — кнопки нет). Слэш-команды `/configs`/`/unlink` продолжают работать как раньше — кнопки лишь
|
||||||
|
вызывают те же обработчики через callback (`menu:configs`/`menu:unlink`/`menu:back`).
|
||||||
|
|
||||||
**Не реализовано / backlog:**
|
**Не реализовано / backlog:**
|
||||||
- QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке).
|
- QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке).
|
||||||
@@ -39,7 +48,7 @@ Telegram-бот — **второй канал доставки** (presentation-
|
|||||||
`PnvPanel.Api/Telegram/`) — условие «фронт+бек в одном контейнере».
|
`PnvPanel.Api/Telegram/`) — условие «фронт+бек в одном контейнере».
|
||||||
- Транспорт — **только long polling** (`ITelegramBotClient.ReceiveAsync`). Webhook рассматривался на
|
- Транспорт — **только long polling** (`ITelegramBotClient.ReceiveAsync`). Webhook рассматривался на
|
||||||
этапе планирования, но не реализован: `TelegramOptions` (`Infrastructure/Telegram/TelegramOptions.cs`)
|
этапе планирования, но не реализован: `TelegramOptions` (`Infrastructure/Telegram/TelegramOptions.cs`)
|
||||||
содержит только `BotToken`, `BotUsername`, `PublicSiteUrl`, `AdminTelegramUserIds` — полей
|
содержит только `BotToken`, `ProxyUrl`, `PublicSiteUrl`, `AdminTelegramUserIds` — полей
|
||||||
`Mode`/`WebhookUrl`/`WebhookSecret` в коде нет.
|
`Mode`/`WebhookUrl`/`WebhookSecret` в коде нет.
|
||||||
- Библиотека — **Telegram.Bot**. Каждый апдейт обрабатывается в своём DI-scope (`PnvBotUpdateHandler`,
|
- Библиотека — **Telegram.Bot**. Каждый апдейт обрабатывается в своём DI-scope (`PnvBotUpdateHandler`,
|
||||||
как HTTP-запрос — свежие scoped-сервисы на апдейт).
|
как HTTP-запрос — свежие scoped-сервисы на апдейт).
|
||||||
@@ -122,16 +131,19 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
|
|
||||||
## Флоу 4 — Просмотр конфигов в боте
|
## Флоу 4 — Просмотр конфигов в боте
|
||||||
|
|
||||||
1. Привязанный пользователь: `/configs`.
|
1. Привязанный пользователь: `/configs` (текстовая команда → новое сообщение) или «📋 Мои конфиги» из
|
||||||
|
главного меню (callback `menu:configs` → редактирует текущее сообщение, см. `BuildConfigsMenuAsync`).
|
||||||
2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`.
|
2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`.
|
||||||
3. Ответ — отдельное сообщение на каждый конфиг: `• {Label ?? Location} ({Protocol}) — {Status}` +
|
3. Текст — **одно** сообщение: `Ваши конфиги:` + по строке `• {Label ?? Location} ({Protocol}) — {Status}`.
|
||||||
inline-кнопка «🔗 Показать ссылку» (кроме отозванных — там кнопки нет). Если конфигов нет —
|
Клавиатура — по кнопке `🔗 {Label}` на каждый **не отозванный** конфиг + «🔙 В меню» внизу. Если
|
||||||
«У вас пока нет конфигов.»
|
конфигов нет — «У вас пока нет конфигов.» с той же кнопкой «В меню».
|
||||||
4. Нажатие кнопки → callback `cfg:link:{configId}` → бот вызывает `GetConfigLinkQuery` (тот же, что
|
4. Нажатие `🔗 {Label}` → callback `cfg:link:{configId}` → бот вызывает `GetConfigLinkQuery` (тот же,
|
||||||
эндпоинт `/api/configs/{id}/link`) от текущего пользователя и **редактирует то же сообщение**
|
что эндпоинт `/api/configs/{id}/link`) от текущего пользователя и **редактирует то же сообщение**
|
||||||
(`EditMessageText`), дописывая ссылку моноширинным блоком (`<code>`, тап = копирование целиком) и
|
(`EditMessageText`): дописывает ссылку моноширинным блоком (`<code>`, тап = копирование целиком) и
|
||||||
убирая кнопку — не плодит отдельное сообщение с сырым URL. Ссылка не раскрывается нигде до явного
|
убирает **именно эту** кнопку из клавиатуры (`InlineKeyboardMarkup.InlineKeyboard`, фильтр по
|
||||||
нажатия. QR-картинки нет — только текст.
|
`CallbackData`) — остальные конфиги и «В меню» остаются кликабельными. Ссылка не раскрывается нигде
|
||||||
|
до явного нажатия, отдельных сообщений не плодится. QR-картинки нет — только текст.
|
||||||
|
5. «🔙 В меню» (`menu:back`) — редактирует сообщение обратно в главное меню (`BuildMainMenu`).
|
||||||
|
|
||||||
## Флоу 5 — Обработка активации админом в боте
|
## Флоу 5 — Обработка активации админом в боте
|
||||||
|
|
||||||
@@ -157,13 +169,17 @@ 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`, «📋 Мои конфиги» (`menu:configs`) | Список конфигов, кнопка `🔗 {Label}` на каждый активный + «🔙 В меню» | да |
|
||||||
| `/unlink` | Отвязать Telegram от аккаунта | да |
|
| `/unlink`, «🔓 Отвязать Telegram» (`menu:unlink`) | Отвязать Telegram от аккаунта | да |
|
||||||
|
| «🔙 В меню» (`menu:back`) | Вернуться из списка конфигов к главному меню (edit-in-place) | нет |
|
||||||
|
| «🌐 Сайт панели» | Открыть сайт (`InlineKeyboardButton.WithUrl`, только если задан `Telegram__PublicSiteUrl`) | нет |
|
||||||
| `/help` | Справка (то же сообщение, что `/start`) | нет |
|
| `/help` | Справка (то же сообщение, что `/start`) | нет |
|
||||||
| «📝 Зарегистрироваться» (`reg:new`) | Регистрация нового аккаунта прямо из бота (Флоу 3) | нет (нужно, чтобы **не** был привязан) |
|
| «📝 Зарегистрироваться» (`reg:new`) | Регистрация нового аккаунта прямо из бота (Флоу 3) | нет (нужно, чтобы **не** был привязан) |
|
||||||
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
|
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
|
||||||
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
|
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
|
||||||
|
|
||||||
|
Главное меню (`/start`/`/help`) — см. пункт 7 в «Возможности» выше.
|
||||||
|
|
||||||
Любой другой текст → «Не понимаю эту команду. /help — список команд.»
|
Любой другой текст → «Не понимаю эту команду. /help — список команд.»
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
@@ -191,9 +207,8 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
"Telegram": {
|
"Telegram": {
|
||||||
"BotToken": "…", // секрет; пусто = бот не стартует
|
"BotToken": "…", // секрет; пусто = бот не стартует
|
||||||
"ProxyUrl": "socks5://[user:pass@]host:port", // прокси для запросов к Bot API; пусто = без прокси
|
"ProxyUrl": "socks5://[user:pass@]host:port", // прокси для запросов к Bot API; пусто = без прокси
|
||||||
|
"PublicSiteUrl": "https://dashboard.example.com", // кнопка «🌐 Сайт панели» в меню; пусто = кнопки нет
|
||||||
"AdminTelegramUserIds": "123456789,987654321" // через запятую
|
"AdminTelegramUserIds": "123456789,987654321" // через запятую
|
||||||
// "PublicSiteUrl" — поле есть в TelegramOptions, но нигде не читается (мёртвый код,
|
|
||||||
// не задавай его — эффекта не будет)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -203,10 +218,10 @@ Username бота для deepLink (кнопка «Привязать Telegram»/
|
|||||||
`BotUsername`, убрано: опечатка/лишний пробел в env ломали ссылку, а источник истины и так есть в
|
`BotUsername`, убрано: опечатка/лишний пробел в env ломали ссылку, а источник истины и так есть в
|
||||||
самом Telegram). Если `BotToken` пуст или `getMe` не отвечает — `deepLink` в ответах API будет `null`.
|
самом Telegram). Если `BotToken` пуст или `getMe` не отвечает — `deepLink` в ответах API будет `null`.
|
||||||
|
|
||||||
Переменные окружения — `Telegram__BotToken`, `Telegram__ProxyUrl`, `Telegram__AdminTelegramUserIds`
|
Переменные окружения — `Telegram__BotToken`, `Telegram__ProxyUrl`, `Telegram__PublicSiteUrl`,
|
||||||
(см. [`.env.example`](../.env.example)). Именно они авторизуют админ-кнопки в боте и определяют,
|
`Telegram__AdminTelegramUserIds` (см. [`.env.example`](../.env.example)). `AdminTelegramUserIds`
|
||||||
кому слать уведомления о запросах активации — **не** сидируются в БД и не связаны с учёткой
|
авторизует админ-кнопки в боте и определяет, кому слать уведомления о запросах активации — **не**
|
||||||
сид-админа (`AdminSeed:*`), это независимый список.
|
сидируется в БД и не связан с учёткой сид-админа (`AdminSeed:*`), это независимый список.
|
||||||
|
|
||||||
Сообщения бота **не локализованы** по языку пользователя — все тексты на русском независимо от языка
|
Сообщения бота **не локализованы** по языку пользователя — все тексты на русском независимо от языка
|
||||||
интерфейса на сайте (в отличие от веба, где RU/EN переключаются). Синхронизация языка бота с вебом —
|
интерфейса на сайте (в отличие от веба, где RU/EN переключаются). Синхронизация языка бота с вебом —
|
||||||
|
|||||||
Reference in New Issue
Block a user