Files
PnvPanel/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs
T
Leonid Pershin b05b76f32f
CI / Backend (build + test) (push) Failing after 1m28s
CI / Frontend (lint + typecheck + build) (push) Successful in 47s
Refactor messaging system to utilize LiteCqrs library
- Replaced instances of the previous messaging system with LiteCqrs across various application components, enhancing the CQRS implementation.
- Updated dependency injection to register LiteCqrs services and behaviors, streamlining command and query handling.
- Adjusted multiple command and query handlers to align with the new messaging framework, ensuring consistent functionality and improved maintainability.
- Added LiteCqrs package reference in the project file for better dependency management.
2026-07-24 04:16:38 +03:00

1071 lines
40 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Extensions.Options;
using PnvPanel.Application.Admin.Activation;
using PnvPanel.Application.Admin.Billing;
using PnvPanel.Application.Admin.Support;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Billing.GetMyBillingStatus;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Configs.GetConfigLink;
using PnvPanel.Application.Configs.GetMyConfigs;
using PnvPanel.Application.Telegram;
using PnvPanel.Application.Telegram.Bot;
using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Configs;
using PnvPanel.Infrastructure.Telegram;
using Telegram.Bot;
using Telegram.Bot.Polling;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace PnvPanel.Api.Telegram;
/// <summary>
/// Обрабатывает апдейты бота. Каждый апдейт — свой DI-scope (как HTTP-запрос), чтобы получить
/// свежие scoped-сервисы (ISender, ICurrentUserSetter, ...). Бот — read-only по конфигам в MVP.
/// </summary>
public sealed class PnvBotUpdateHandler(
IServiceScopeFactory scopeFactory,
IOptions<TelegramOptions> options,
ILogger<PnvBotUpdateHandler> logger
) : IUpdateHandler
{
// Показывается везде, где боту нужен привязанный аккаунт, а его нет — явно проговариваем оба шага,
// иначе новые пользователи не понимают, что сначала нужен обычный аккаунт на сайте.
private const string NotLinkedMessage =
"Сначала зарегистрируйтесь и войдите на сайте, затем привяжите Telegram: Настройки → «Привязать Telegram».";
public async Task HandleUpdateAsync(
ITelegramBotClient botClient,
Update update,
CancellationToken cancellationToken
)
{
await using var scope = scopeFactory.CreateAsyncScope();
try
{
if (update.Message is { Text: { } text } message)
await HandleMessageAsync(
botClient,
scope.ServiceProvider,
message,
text,
cancellationToken
);
else if (update.CallbackQuery is { } callback)
await HandleCallbackAsync(
botClient,
scope.ServiceProvider,
callback,
cancellationToken
);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process Telegram update {UpdateId}", update.Id);
}
}
public Task HandleErrorAsync(
ITelegramBotClient botClient,
Exception exception,
HandleErrorSource source,
CancellationToken cancellationToken
)
{
logger.LogError(exception, "Telegram bot error (source {Source})", source);
return Task.CompletedTask;
}
private async Task HandleMessageAsync(
ITelegramBotClient botClient,
IServiceProvider services,
Message message,
string text,
CancellationToken cancellationToken
)
{
var chatId = message.Chat.Id;
var fromId = message.From?.Id;
if (fromId is null)
return;
if (text.StartsWith("/start", StringComparison.Ordinal))
{
var payload = text.Length > 7 ? text[7..].Trim() : string.Empty;
if (payload.StartsWith("link_", StringComparison.Ordinal))
await HandleLinkAsync(
botClient,
services,
chatId,
fromId.Value,
message.From?.Username,
payload[5..],
cancellationToken
);
else if (payload.StartsWith("login_", StringComparison.Ordinal))
await HandleLoginPromptAsync(
botClient,
services,
chatId,
fromId.Value,
payload[6..],
cancellationToken
);
else
await SendWelcomeAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
return;
}
switch (text)
{
case "/configs":
await HandleConfigsAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break;
case "/billing":
await HandleBillingStatusAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break;
case "/unlink":
await HandleUnlinkAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break;
case "/requests":
await HandleRequestsAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break;
case "/help":
await SendWelcomeAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break;
default:
await botClient.SendMessage(
chatId,
"Не понимаю эту команду. /help — список команд.",
cancellationToken: cancellationToken
);
break;
}
}
private async Task HandleCallbackAsync(
ITelegramBotClient botClient,
IServiceProvider services,
CallbackQuery callback,
CancellationToken cancellationToken
)
{
var data = callback.Data;
var chatId = callback.Message?.Chat.Id;
var fromId = callback.From.Id;
if (data is null || chatId is null)
return;
if (data == "reg:new")
{
await HandleRegisterCallbackAsync(
botClient,
services,
chatId.Value,
fromId,
callback.From.Username,
callback.Id,
cancellationToken
);
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:billing")
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(
callback.Id,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return;
}
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
var (billingText, billingKeyboard) = await BuildBillingStatusMenuAsync(
services,
cancellationToken
);
if (callback.Message is not null)
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
billingText,
replyMarkup: billingKeyboard,
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 linkedProfile =
linkedUserId is { } uid ? await identityService.GetProfileAsync(uid, cancellationToken) : null;
var (menuText, menuKeyboard) = BuildMainMenu(
isLinked: linkedUserId is not null,
billingEnabled: linkedProfile?.BillingEnabled ?? false
);
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;
var sender = services.GetRequiredService<ISender>();
switch (parts[0])
{
case "login":
{
var result =
parts[1] == "approve"
? await sender.Send(
new ApproveTelegramLoginCommand(requestId, fromId),
cancellationToken
)
: await sender.Send(
new RejectTelegramLoginCommand(requestId, fromId),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
var statusText = result.IsSuccess
? (parts[1] == "approve" ? "✅ Вход подтверждён." : "❌ Вход отклонён.")
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
}
case "act":
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
var result =
parts[1] == "approve"
? await sender.Send(
new ApproveActivationCommand(requestId),
cancellationToken
)
: await sender.Send(
new RejectActivationCommand(requestId, Reason: null),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
// Редактируем исходное сообщение с запросом вместо отдельного — иначе кнопки
// «Активировать/Отклонить» остаются висеть под уже обработанным запросом (в т.ч.
// если его обработали в другом месте — на сайте или из другого чата).
var statusText = result.IsSuccess
? (
parts[1] == "approve"
? "✅ Пользователь активирован."
: "❌ Запрос отклонён."
)
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
}
case "erq":
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
var result =
parts[1] == "approve"
? await sender.Send(
new ApproveExtensionRequestCommand(requestId),
cancellationToken
)
: await sender.Send(
new RejectExtensionRequestCommand(requestId, Reason: null),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
var statusText = result.IsSuccess
? (
parts[1] == "approve"
? "✅ Заявка на продление одобрена."
: "❌ Заявка отклонена."
)
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
break;
}
case "pay":
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
var result =
parts[1] == "approve"
? await sender.Send(
new ConfirmPaymentRequestCommand(requestId),
cancellationToken
)
: await sender.Send(
new RejectPaymentRequestCommand(requestId, Reason: null),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null)
{
var statusText = result.IsSuccess
? (
parts[1] == "approve"
? "✅ Оплата подтверждена."
: "❌ Оплата отклонена."
)
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
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
);
if (!linkResult.IsSuccess)
{
await botClient.SendMessage(
chatId.Value,
$"Не удалось получить ссылку: {linkResult.Error.Message}",
cancellationToken: cancellationToken
);
return;
}
if (callback.Message is null)
break;
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
// блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением.
// Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте.
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();
await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
replyMarkup: remainingRows.Length > 0
? new InlineKeyboardMarkup(remainingRows)
: null,
cancellationToken: cancellationToken
);
break;
}
}
}
private async Task HandleLinkAsync(
ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
string? username,
string token,
CancellationToken cancellationToken
)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(
new LinkTelegramCommand(token, fromId, username),
cancellationToken
);
var text = result.IsSuccess
? "✅ Telegram успешно привязан к вашему аккаунту."
: $"❌ Не удалось привязать: {result.Error.Message}";
await botClient.SendMessage(chatId, text, cancellationToken: cancellationToken);
}
private async Task HandleLoginPromptAsync(
ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
string requestIdRaw,
CancellationToken cancellationToken
)
{
if (!Guid.TryParse(requestIdRaw, out var requestId))
{
await botClient.SendMessage(
chatId,
"Некорректная ссылка входа.",
cancellationToken: cancellationToken
);
return;
}
var identityService = services.GetRequiredService<IIdentityService>();
var userId = await identityService.FindUserIdByTelegramUserIdAsync(
fromId,
cancellationToken
);
if (userId is null)
{
await botClient.SendMessage(
chatId,
NotLinkedMessage,
replyMarkup: new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new"),
}
),
cancellationToken: cancellationToken
);
return;
}
var keyboard = new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData(
"✅ Подтвердить вход",
$"login:approve:{requestId}"
),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"login:reject:{requestId}"),
}
);
await botClient.SendMessage(
chatId,
"Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?",
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
private async Task HandleConfigsAsync(
ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
CancellationToken cancellationToken
)
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.SendMessage(
chatId,
NotLinkedMessage,
replyMarkup: new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new"),
}
),
cancellationToken: cancellationToken
);
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)
return ("У вас пока нет конфигов.", BackToMenuKeyboard());
var text =
"Ваши конфиги:\n"
+ string.Join(
'\n',
result.Value.Configs.Select(c =>
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"
)
);
// Отозванному конфигу нечего показывать — кнопку не даём.
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();
return (text, new InlineKeyboardMarkup(rows));
}
private async Task HandleBillingStatusAsync(
ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
CancellationToken cancellationToken
)
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.SendMessage(chatId, NotLinkedMessage, cancellationToken: cancellationToken);
return;
}
var (text, keyboard) = await BuildBillingStatusMenuAsync(services, cancellationToken);
await botClient.SendMessage(
chatId,
text,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
/// <summary>До какого числа оплачено + остаток в человекочитаемом виде (дни, либо часы/минуты,
/// если меньше суток) — то же форматирование, что и на сайте (PaidUntilBadge).</summary>
private static async Task<(string Text, InlineKeyboardMarkup Keyboard)> BuildBillingStatusMenuAsync(
IServiceProvider services,
CancellationToken cancellationToken
)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new GetMyBillingStatusQuery(), cancellationToken);
if (!result.IsSuccess)
return ($"⚠️ {result.Error.Message}", BackToMenuKeyboard());
var status = result.Value;
if (!status.BillingEnabled)
return ("Биллинг не применяется к вашей роли.", BackToMenuKeyboard());
if (status.PaidUntil is not { } paidUntil)
return ("Оплата ещё не производилась.", BackToMenuKeyboard());
var text =
$"💳 Оплачено до {paidUntil:dd.MM.yyyy HH:mm}\nОсталось: {FormatRemaining(paidUntil)}"
+ (status.Suspended ? "\n\n⛔ Конфиги приостановлены за неуплату." : "");
return (text, BackToMenuKeyboard());
}
private static string FormatRemaining(DateTimeOffset paidUntil)
{
var remaining = paidUntil - DateTimeOffset.UtcNow;
if (remaining <= TimeSpan.Zero)
return "истекло";
if (remaining < TimeSpan.FromDays(1))
{
var totalMinutes = (int)Math.Ceiling(remaining.TotalMinutes);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
return hours > 0 ? $"{hours} ч {minutes} мин" : $"{minutes} мин";
}
var days = (int)Math.Ceiling(remaining.TotalDays);
return $"{days} дн.";
}
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
)
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.SendMessage(
chatId,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return;
}
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken);
await botClient.SendMessage(
chatId,
result.IsSuccess ? "Telegram отвязан от аккаунта." : $"Ошибка: {result.Error.Message}",
cancellationToken: cancellationToken
);
}
private async Task HandleRequestsAsync(
ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
CancellationToken cancellationToken
)
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.SendMessage(
chatId,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(
new ListActivationRequestsQuery(ActivationStatus.Pending, 1, 10),
cancellationToken
);
if (!result.IsSuccess || result.Value.Items.Count == 0)
{
await botClient.SendMessage(
chatId,
"Нет ожидающих запросов на активацию.",
cancellationToken: cancellationToken
);
return;
}
foreach (var item in result.Value.Items)
{
var text =
$"Запрос от <b>{item.UserName}</b>"
+ (string.IsNullOrWhiteSpace(item.Comment) ? "" : $"\n{item.Comment}");
var keyboard = new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData(
"✅ Активировать",
$"act:approve:{item.Id}"
),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{item.Id}"),
}
);
await botClient.SendMessage(
chatId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
}
private async Task SendWelcomeAsync(
ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
CancellationToken cancellationToken
)
{
var identityService = services.GetRequiredService<IIdentityService>();
var userId = await identityService.FindUserIdByTelegramUserIdAsync(
fromId,
cancellationToken
);
var profile =
userId is { } uid ? await identityService.GetProfileAsync(uid, cancellationToken) : null;
var (text, keyboard) = BuildMainMenu(
isLinked: userId is not null,
billingEnabled: profile?.BillingEnabled ?? false
);
await botClient.SendMessage(
chatId,
text,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
/// <summary>Привязанному аккаунту — кнопки-действия вместо текстовых команд; непривязанному —
/// только регистрация (остальное ему всё равно недоступно). Кнопка на сайт — если задан PublicSiteUrl.
/// billingEnabled — показать «Статус оплаты» только для billing-ролей (см. AppRole.BillingEnabled).</summary>
private (string Text, InlineKeyboardMarkup Keyboard) BuildMainMenu(bool isLinked, bool billingEnabled = false)
{
const string text =
"Привет! Это бот PnvPanel.\n\n"
+ "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.";
var rows = new List<InlineKeyboardButton[]>();
if (isLinked)
{
rows.Add(
new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") }
);
if (billingEnabled)
rows.Add(
new[]
{
InlineKeyboardButton.WithCallbackData("💳 Статус оплаты", "menu:billing"),
}
);
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
)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(
new RegisterViaTelegramCommand(fromId, username),
cancellationToken
);
await botClient.AnswerCallbackQuery(callbackId, cancellationToken: cancellationToken);
if (!result.IsSuccess)
{
await botClient.SendMessage(
chatId,
$"Не удалось зарегистрироваться: {result.Error.Message}",
cancellationToken: cancellationToken
);
return;
}
var text =
"✅ Аккаунт создан.\n\n"
+ $"Логин: <code>{result.Value.UserName}</code>\n"
+ $"Пароль: <code>{result.Value.Password}</code>\n\n"
+ "Сохраните пароль — он присылается только один раз. Логин можно сменить в Настройках на сайте.\n\n"
+ "Дальше нужно дождаться активации администратором — после неё будут доступны конфиги. "
+ "Входить можно как по паролю, так и кнопкой «Войти через Telegram».";
await botClient.SendMessage(
chatId,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
}
private static string Escape(string text) =>
text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
private static async Task<bool> TrySetCurrentUserAsync(
IServiceProvider services,
long telegramUserId,
CancellationToken cancellationToken
)
{
var identityService = services.GetRequiredService<IIdentityService>();
var userId = await identityService.FindUserIdByTelegramUserIdAsync(
telegramUserId,
cancellationToken
);
if (userId is null)
return false;
var profile = await identityService.GetProfileAsync(userId.Value, cancellationToken);
if (profile is null)
return false;
services.GetRequiredService<ICurrentUserSetter>().SetUser(profile.Id, profile.UserName);
return true;
}
private async Task<bool> TrySetAdminCurrentUserAsync(
IServiceProvider services,
long telegramUserId,
CancellationToken cancellationToken
)
{
if (!options.Value.ParseAdminTelegramUserIds().Contains(telegramUserId))
return false;
return await TrySetCurrentUserAsync(services, telegramUserId, cancellationToken);
}
}