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:
@@ -110,6 +110,68 @@ public sealed class PnvBotUpdateHandler(
|
||||
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(':');
|
||||
if (parts.Length != 3 || !Guid.TryParse(parts[2], out var requestId))
|
||||
return;
|
||||
@@ -171,13 +233,21 @@ public sealed class PnvBotUpdateHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (callback.Message is null)
|
||||
break;
|
||||
|
||||
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
|
||||
// блоком, по нему в 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(chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html,
|
||||
replyMarkup: remainingRows.Length > 0 ? new InlineKeyboardMarkup(remainingRows) : null,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -241,28 +311,38 @@ public sealed class PnvBotUpdateHandler(
|
||||
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 result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
|
||||
|
||||
if (!result.IsSuccess || result.Value.Configs.Count == 0)
|
||||
{
|
||||
await botClient.SendMessage(chatId, "У вас пока нет конфигов.", cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
return ("У вас пока нет конфигов.", BackToMenuKeyboard());
|
||||
|
||||
foreach (var config in result.Value.Configs)
|
||||
{
|
||||
var text = $"• {config.Label ?? config.Location} ({config.Protocol}) — {config.Status}";
|
||||
var text = "Ваши конфиги:\n" + string.Join('\n', result.Value.Configs.Select(c =>
|
||||
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"));
|
||||
|
||||
// Отозванному конфигу нечего показывать — кнопку не даём.
|
||||
InlineKeyboardMarkup? keyboard = config.Status == ConfigStatus.Revoked
|
||||
? null
|
||||
: new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("🔗 Показать ссылку", $"cfg:link:{config.Id}") });
|
||||
// Отозванному конфигу нечего показывать — кнопку не даём.
|
||||
var rows = result.Value.Configs
|
||||
.Where(c => c.Status != ConfigStatus.Revoked)
|
||||
.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(
|
||||
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)
|
||||
{
|
||||
const string text = "Привет! Это бот PnvPanel.\n\n"
|
||||
+ "/configs — мои конфиги\n"
|
||||
+ "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.\n"
|
||||
+ "/unlink — отвязать Telegram\n"
|
||||
+ "/help — эта справка";
|
||||
|
||||
var identityService = services.GetRequiredService<IIdentityService>();
|
||||
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
|
||||
|
||||
// Уже привязанным аккаунту предлагать регистрацию незачем.
|
||||
var keyboard = userId is null
|
||||
? new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") })
|
||||
: null;
|
||||
|
||||
var (text, keyboard) = BuildMainMenu(isLinked: userId is not null);
|
||||
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(
|
||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string callbackId,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -5,6 +5,8 @@ public sealed class TelegramOptions
|
||||
public const string SectionName = "Telegram";
|
||||
|
||||
public string? BotToken { get; init; }
|
||||
|
||||
/// <summary>Ссылка на сайт панели — кнопка «🌐 Сайт панели» в главном меню бота. Пусто — кнопки нет.</summary>
|
||||
public string PublicSiteUrl { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port). Пусто — без прокси.</summary>
|
||||
|
||||
Reference in New Issue
Block a user