Files
PnvPanel/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs
T
Leonid Pershin fad03c2834
CI / Backend (build + test) (push) Failing after 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
Enhance user plan management and update related endpoints
- 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.
2026-07-23 22:52:20 +03:00

365 lines
13 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.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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}