- Added new configuration options for user plans in `.env.example`, including `Plans__MaxCustomConfigCount` and `Plans__MinCustomConfigCount`. - Introduced `MapPlanEndpoints` in `Program.cs` to handle plan-related API routes. - Implemented `SetUserPlan` endpoint in `RoleEndpoints` to allow admins to assign plans to users. - Removed deprecated role request approval endpoints from `AdminSupportEndpoints`. - Updated `ITelegramNotifier` and related classes to reflect changes in role request handling and payment notifications. - Refactored role management commands to remove `MaxConfigs` and focus on `MaxIpLimit` and billing settings. - Enhanced billing request handling to accommodate plan changes instead of role changes. - Updated various interfaces and command handlers to support new plan management features.
365 lines
13 KiB
C#
365 lines
13 KiB
C#
using Microsoft.Extensions.Options;
|
||
using PnvPanel.Application.Common.Interfaces;
|
||
using PnvPanel.Domain.Billing;
|
||
using PnvPanel.Domain.Nodes;
|
||
using PnvPanel.Domain.Support;
|
||
using PnvPanel.Infrastructure.Telegram;
|
||
using Telegram.Bot;
|
||
using Telegram.Bot.Types.Enums;
|
||
using Telegram.Bot.Types.ReplyMarkups;
|
||
|
||
namespace PnvPanel.Api.Telegram;
|
||
|
||
internal sealed class TelegramNotifier(
|
||
ITelegramBotClient botClient,
|
||
IIdentityService identityService,
|
||
IOptions<TelegramOptions> options
|
||
) : ITelegramNotifier
|
||
{
|
||
public async Task NotifyAdminsActivationRequestedAsync(
|
||
Guid requestId,
|
||
string userName,
|
||
string? comment,
|
||
CancellationToken cancellationToken
|
||
)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||
return;
|
||
|
||
var text =
|
||
$"🆕 Запрос на активацию от <b>{Escape(userName)}</b>"
|
||
+ (
|
||
string.IsNullOrWhiteSpace(comment)
|
||
? string.Empty
|
||
: $"\nКомментарий: {Escape(comment)}"
|
||
);
|
||
|
||
var keyboard = new InlineKeyboardMarkup(
|
||
new[]
|
||
{
|
||
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{requestId}"),
|
||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{requestId}"),
|
||
}
|
||
);
|
||
|
||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||
{
|
||
try
|
||
{
|
||
await botClient.SendMessage(
|
||
adminId,
|
||
text,
|
||
parseMode: ParseMode.Html,
|
||
replyMarkup: keyboard,
|
||
cancellationToken: cancellationToken
|
||
);
|
||
}
|
||
catch
|
||
{
|
||
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||
}
|
||
}
|
||
}
|
||
|
||
public async Task NotifyAdminsBugReportCreatedAsync(
|
||
Guid ticketId,
|
||
string userName,
|
||
string message,
|
||
CancellationToken cancellationToken
|
||
)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||
return;
|
||
|
||
var preview = message.Length > 300 ? message[..300] + "…" : message;
|
||
var text =
|
||
$"🐞 Новый тикет (баг/предложение) от <b>{Escape(userName)}</b>\n{Escape(preview)}";
|
||
|
||
// Только ссылка на сайт — переписка и картинки удобнее там, инлайн-действий для баг-тикетов нет.
|
||
InlineKeyboardMarkup? keyboard = null;
|
||
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||
{
|
||
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support?ticket={ticketId}";
|
||
keyboard = new InlineKeyboardMarkup(
|
||
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
|
||
);
|
||
}
|
||
|
||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||
{
|
||
try
|
||
{
|
||
await botClient.SendMessage(
|
||
adminId,
|
||
text,
|
||
parseMode: ParseMode.Html,
|
||
replyMarkup: keyboard,
|
||
cancellationToken: cancellationToken
|
||
);
|
||
}
|
||
catch
|
||
{
|
||
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||
}
|
||
}
|
||
}
|
||
|
||
public async Task NotifyAdminsExtensionRequestCreatedAsync(
|
||
Guid ticketId,
|
||
string userName,
|
||
int requestedDays,
|
||
string justification,
|
||
CancellationToken cancellationToken
|
||
)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||
return;
|
||
|
||
var text =
|
||
$"🆕 Заявка на продление от <b>{Escape(userName)}</b> — {requestedDays} дн.\nОбоснование: {Escape(justification)}";
|
||
|
||
var keyboard = new InlineKeyboardMarkup(
|
||
new[]
|
||
{
|
||
InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"erq:approve:{ticketId}"),
|
||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"erq:reject:{ticketId}"),
|
||
}
|
||
);
|
||
|
||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||
{
|
||
try
|
||
{
|
||
await botClient.SendMessage(
|
||
adminId,
|
||
text,
|
||
parseMode: ParseMode.Html,
|
||
replyMarkup: keyboard,
|
||
cancellationToken: cancellationToken
|
||
);
|
||
}
|
||
catch
|
||
{
|
||
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||
}
|
||
}
|
||
}
|
||
|
||
public async Task NotifyAdminsTicketReopenedAsync(
|
||
Guid ticketId,
|
||
string userName,
|
||
TicketType type,
|
||
CancellationToken cancellationToken
|
||
)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||
return;
|
||
|
||
var text = $"🔓 Тикет от <b>{Escape(userName)}</b> переоткрыт (баг/предложение)";
|
||
|
||
InlineKeyboardMarkup? keyboard = null;
|
||
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||
{
|
||
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support?ticket={ticketId}";
|
||
keyboard = new InlineKeyboardMarkup(
|
||
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
|
||
);
|
||
}
|
||
|
||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||
{
|
||
try
|
||
{
|
||
await botClient.SendMessage(
|
||
adminId,
|
||
text,
|
||
parseMode: ParseMode.Html,
|
||
replyMarkup: keyboard,
|
||
cancellationToken: cancellationToken
|
||
);
|
||
}
|
||
catch
|
||
{
|
||
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||
}
|
||
}
|
||
}
|
||
|
||
public async Task NotifyUsersNewsPublishedAsync(
|
||
string title,
|
||
CancellationToken cancellationToken
|
||
)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||
return;
|
||
|
||
var text = $"📰 Новая новость: <b>{Escape(title)}</b>";
|
||
|
||
InlineKeyboardMarkup? keyboard = null;
|
||
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||
{
|
||
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/news";
|
||
keyboard = new InlineKeyboardMarkup(
|
||
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
|
||
);
|
||
}
|
||
|
||
var telegramUserIds = await identityService.GetActivatedLinkedTelegramUserIdsAsync(
|
||
cancellationToken
|
||
);
|
||
foreach (var telegramUserId in telegramUserIds)
|
||
{
|
||
try
|
||
{
|
||
await botClient.SendMessage(
|
||
telegramUserId,
|
||
text,
|
||
parseMode: ParseMode.Html,
|
||
replyMarkup: keyboard,
|
||
cancellationToken: cancellationToken
|
||
);
|
||
}
|
||
catch
|
||
{
|
||
// Пользователь мог заблокировать бота — не критично, продолжаем рассылку остальным.
|
||
}
|
||
}
|
||
}
|
||
|
||
public async Task NotifyAdminsPaymentRequestedAsync(
|
||
Guid requestId,
|
||
string userName,
|
||
PaymentRequestKind kind,
|
||
PaymentPeriod? period,
|
||
int amount,
|
||
CancellationToken cancellationToken
|
||
)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||
return;
|
||
|
||
var reason = kind == PaymentRequestKind.PlanChangeTopUp ? "доплате за смену тарифа" : $"оплате за {PeriodLabel(period!.Value)}";
|
||
var text = $"💰 <b>{Escape(userName)}</b> заявляет о {reason} — {amount} ₽\nПроверьте поступление и подтвердите.";
|
||
|
||
var keyboard = new InlineKeyboardMarkup(
|
||
new[]
|
||
{
|
||
InlineKeyboardButton.WithCallbackData("✅ Подтвердить", $"pay:approve:{requestId}"),
|
||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"pay:reject:{requestId}"),
|
||
}
|
||
);
|
||
|
||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||
{
|
||
try
|
||
{
|
||
await botClient.SendMessage(
|
||
adminId,
|
||
text,
|
||
parseMode: ParseMode.Html,
|
||
replyMarkup: keyboard,
|
||
cancellationToken: cancellationToken
|
||
);
|
||
}
|
||
catch
|
||
{
|
||
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||
}
|
||
}
|
||
}
|
||
|
||
private static string PeriodLabel(PaymentPeriod period) =>
|
||
period switch
|
||
{
|
||
PaymentPeriod.Quarter => "3 месяца",
|
||
PaymentPeriod.HalfYear => "полгода",
|
||
PaymentPeriod.Year => "год",
|
||
_ => period.ToString(),
|
||
};
|
||
|
||
public async Task NotifyUserAsync(
|
||
Guid userId,
|
||
string message,
|
||
string? linkPath,
|
||
CancellationToken cancellationToken
|
||
)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||
return;
|
||
|
||
var link = await identityService.GetTelegramLinkInfoAsync(userId, cancellationToken);
|
||
if (!link.IsLinked || link.TelegramUserId is not { } telegramUserId)
|
||
return;
|
||
|
||
InlineKeyboardMarkup? keyboard = null;
|
||
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||
{
|
||
var url = string.IsNullOrWhiteSpace(linkPath)
|
||
? options.Value.PublicSiteUrl
|
||
: $"{options.Value.PublicSiteUrl.TrimEnd('/')}{linkPath}";
|
||
keyboard = new InlineKeyboardMarkup(
|
||
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
|
||
);
|
||
}
|
||
|
||
try
|
||
{
|
||
await botClient.SendMessage(
|
||
telegramUserId,
|
||
message,
|
||
replyMarkup: keyboard,
|
||
cancellationToken: cancellationToken
|
||
);
|
||
}
|
||
catch
|
||
{
|
||
// Пользователь мог заблокировать бота — не критично для основной операции.
|
||
}
|
||
}
|
||
|
||
public async Task NotifyAdminsNodeStatusChangedAsync(
|
||
Guid nodeId,
|
||
string nodeName,
|
||
NodeStatus status,
|
||
CancellationToken cancellationToken
|
||
)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||
return;
|
||
|
||
var emoji = status == NodeStatus.Online ? "✅" : "🔴";
|
||
var statusLabel = status == NodeStatus.Online ? "снова в сети" : "недоступна";
|
||
var text = $"{emoji} Нода <b>{Escape(nodeName)}</b> {statusLabel}.";
|
||
|
||
InlineKeyboardMarkup? keyboard = null;
|
||
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||
{
|
||
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/nodes";
|
||
keyboard = new InlineKeyboardMarkup(
|
||
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
|
||
);
|
||
}
|
||
|
||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||
{
|
||
try
|
||
{
|
||
await botClient.SendMessage(
|
||
adminId,
|
||
text,
|
||
parseMode: ParseMode.Html,
|
||
replyMarkup: keyboard,
|
||
cancellationToken: cancellationToken
|
||
);
|
||
}
|
||
catch
|
||
{
|
||
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||
}
|
||
}
|
||
}
|
||
|
||
private static string Escape(string text) =>
|
||
text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||
}
|