Implement billing status feature in Telegram bot
- Added a new command `/billing` and a corresponding menu option for users with billing roles to check their payment status. - Implemented `HandleBillingStatusAsync` method to retrieve and display billing information, including the payment expiration date and remaining time in a user-friendly format. - Updated the main menu to conditionally show the billing status option based on the user's role. - Enhanced the `PaidUntilBadge` component to format and display the remaining time until the next payment in both days and hours/minutes. - Updated documentation to reflect the new billing status feature and its usage in the Telegram bot.
This commit is contained in:
@@ -2,6 +2,8 @@ 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 PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Configs.GetConfigLink;
|
||||
@@ -136,6 +138,15 @@ public sealed class PnvBotUpdateHandler(
|
||||
cancellationToken
|
||||
);
|
||||
break;
|
||||
case "/billing":
|
||||
await HandleBillingStatusAsync(
|
||||
botClient,
|
||||
services,
|
||||
chatId,
|
||||
fromId.Value,
|
||||
cancellationToken
|
||||
);
|
||||
break;
|
||||
case "/unlink":
|
||||
await HandleUnlinkAsync(
|
||||
botClient,
|
||||
@@ -230,6 +241,36 @@ public sealed class PnvBotUpdateHandler(
|
||||
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);
|
||||
@@ -241,7 +282,12 @@ public sealed class PnvBotUpdateHandler(
|
||||
fromId,
|
||||
cancellationToken
|
||||
);
|
||||
var (menuText, menuKeyboard) = BuildMainMenu(isLinked: linkedUserId is not null);
|
||||
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,
|
||||
@@ -706,6 +752,74 @@ public sealed class PnvBotUpdateHandler(
|
||||
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") };
|
||||
|
||||
@@ -811,8 +925,13 @@ public sealed class PnvBotUpdateHandler(
|
||||
fromId,
|
||||
cancellationToken
|
||||
);
|
||||
var profile =
|
||||
userId is { } uid ? await identityService.GetProfileAsync(uid, cancellationToken) : null;
|
||||
|
||||
var (text, keyboard) = BuildMainMenu(isLinked: userId is not null);
|
||||
var (text, keyboard) = BuildMainMenu(
|
||||
isLinked: userId is not null,
|
||||
billingEnabled: profile?.BillingEnabled ?? false
|
||||
);
|
||||
await botClient.SendMessage(
|
||||
chatId,
|
||||
text,
|
||||
@@ -822,8 +941,9 @@ public sealed class PnvBotUpdateHandler(
|
||||
}
|
||||
|
||||
/// <summary>Привязанному аккаунту — кнопки-действия вместо текстовых команд; непривязанному —
|
||||
/// только регистрация (остальное ему всё равно недоступно). Кнопка на сайт — если задан PublicSiteUrl.</summary>
|
||||
private (string Text, InlineKeyboardMarkup Keyboard) BuildMainMenu(bool isLinked)
|
||||
/// только регистрация (остальное ему всё равно недоступно). Кнопка на сайт — если задан PublicSiteUrl.
|
||||
/// billingEnabled — показать «Статус оплаты» только для billing-ролей (см. AppRole.BillingEnabled).</summary>
|
||||
private (string Text, InlineKeyboardMarkup Keyboard) BuildMainMenu(bool isLinked, bool billingEnabled = false)
|
||||
{
|
||||
const string text =
|
||||
"Привет! Это бот PnvPanel.\n\n"
|
||||
@@ -835,6 +955,13 @@ public sealed class PnvBotUpdateHandler(
|
||||
rows.Add(
|
||||
new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") }
|
||||
);
|
||||
if (billingEnabled)
|
||||
rows.Add(
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("💳 Статус оплаты", "menu:billing"),
|
||||
}
|
||||
);
|
||||
rows.Add(
|
||||
new[]
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user