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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user