Add Telegram bot integration and enhance user management features

- Introduced Telegram.Bot package for bot functionality.
- Updated user management to include Telegram linking and blocking features.
- Enhanced activation request handling with notifications via Telegram.
- Added new database entities for Telegram link tokens and login requests.
- Implemented traffic synchronization for client stats in the XuiPanelGateway.
- Updated application structure to support new test projects and improved dependency injection for Telegram services.
This commit is contained in:
Leonid Pershin
2026-07-02 01:01:03 +03:00
parent 1a8d33efa3
commit 7b6fe9ad78
142 changed files with 7570 additions and 22 deletions
@@ -0,0 +1,293 @@
using Microsoft.Extensions.Options;
using PnvPanel.Application.Admin.Activation;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Configs.GetMyConfigs;
using PnvPanel.Application.Telegram;
using PnvPanel.Application.Telegram.Bot;
using PnvPanel.Domain.Activation;
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
{
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, "Ошибка обработки Telegram-апдейта {UpdateId}", update.Id);
}
}
public Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, HandleErrorSource source, CancellationToken cancellationToken)
{
logger.LogError(exception, "Ошибка Telegram-бота (источник {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, chatId, cancellationToken);
return;
}
switch (text)
{
case "/configs":
await HandleConfigsAsync(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, chatId, 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;
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 (result.IsSuccess)
{
var text = parts[1] == "approve" ? "✅ Вход подтверждён." : "❌ Вход отклонён.";
await botClient.SendMessage(chatId.Value, text, 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 (result.IsSuccess)
{
var text = parts[1] == "approve" ? "✅ Пользователь активирован." : "❌ Запрос отклонён.";
await botClient.SendMessage(chatId.Value, text, 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, "Сначала привяжите Telegram к аккаунту на сайте.", 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, "Сначала привяжите Telegram к аккаунту на сайте (Настройки → Привязать Telegram).",
cancellationToken: cancellationToken);
return;
}
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
if (!result.IsSuccess || result.Value.Configs.Count == 0)
{
await botClient.SendMessage(chatId, "У вас пока нет конфигов.", cancellationToken: cancellationToken);
return;
}
var lines = result.Value.Configs.Select(c =>
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}");
await botClient.SendMessage(chatId, "Ваши конфиги:\n" + string.Join('\n', lines), cancellationToken: cancellationToken);
}
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 static async Task SendWelcomeAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken)
{
const string text = "Привет! Это бот PnvPanel.\n\n"
+ "/configs — мои конфиги\n"
+ "/login — войти на сайт без пароля\n"
+ "/unlink — отвязать Telegram\n"
+ "/help — эта справка";
await botClient.SendMessage(chatId, text, cancellationToken: cancellationToken);
}
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);
}
}