- 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.
1071 lines
40 KiB
C#
1071 lines
40 KiB
C#
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("&", "&").Replace("<", "<").Replace(">", ">");
|
||
|
||
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);
|
||
}
|
||
}
|