Files
2026-03-11 13:10:31 +03:00

420 lines
14 KiB
C#

using System.Globalization;
using System.Text;
using NLog;
using PepperBot.Application.Interfaces;
using PepperBot.Domain;
using Telegram.Bot;
using Telegram.Bot.Exceptions;
using Telegram.Bot.Polling;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace PepperBot.Infrastructure.Telegram;
public class TelegramNotifier : ITelegramNotifier
{
private static readonly ILogger Logger = LogManager.GetCurrentClassLogger();
private readonly ITelegramBotClient _botClient;
private readonly ISubscriptionRepository _subscriptionRepository;
private readonly string _broadcastChatId;
private bool _started;
private enum ConversationState
{
None = 0,
AwaitingAddKeyword = 1
}
private readonly Dictionary<string, ConversationState> _chatStates = new();
public TelegramNotifier(
ITelegramBotClient botClient,
ISubscriptionRepository subscriptionRepository)
{
_botClient = botClient;
_subscriptionRepository = subscriptionRepository;
_broadcastChatId = Environment.GetEnvironmentVariable("TELEGRAM_CHAT_ID") ?? string.Empty;
if (string.IsNullOrWhiteSpace(_broadcastChatId))
{
Logger.Info("TELEGRAM_CHAT_ID не задан. Общая рассылка по скидкам будет отключена.");
}
}
public Task NotifyNewDealAsync(Deal deal, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_broadcastChatId))
{
return Task.CompletedTask;
}
return SendDealAsync(deal, _broadcastChatId, selectionRule: null, cancellationToken);
}
public Task NotifyDealToChatAsync(Deal deal, string chatId, string? selectionRule, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(chatId))
{
return Task.CompletedTask;
}
return SendDealAsync(deal, chatId, selectionRule, cancellationToken);
}
public Task StartAsync(CancellationToken cancellationToken)
{
if (_started)
{
return Task.CompletedTask;
}
var receiverOptions = new ReceiverOptions
{
AllowedUpdates = Array.Empty<UpdateType>()
};
_botClient.StartReceiving(
HandleUpdateAsync,
HandleErrorAsync,
receiverOptions,
cancellationToken);
_started = true;
Logger.Info("Запущена обработка входящих сообщений Telegram.");
return Task.CompletedTask;
}
private async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
{
if (update.Type == UpdateType.CallbackQuery)
{
await HandleCallbackQueryAsync(botClient, update.CallbackQuery!, cancellationToken);
return;
}
if (update.Type != UpdateType.Message)
{
return;
}
var message = update.Message;
if (message is null)
{
return;
}
if (message.Type != MessageType.Text)
{
return;
}
// Нас интересуют только личные сообщения боту
if (message.Chat.Type != ChatType.Private)
{
return;
}
var chatId = message.Chat.Id.ToString(CultureInfo.InvariantCulture);
var text = message.Text?.Trim();
if (string.IsNullOrWhiteSpace(text))
{
return;
}
// Команда для вызова меню
if (text.Equals("/menu", StringComparison.OrdinalIgnoreCase))
{
await SendMainMenuAsync(message.Chat.Id, cancellationToken);
_chatStates[chatId] = ConversationState.None;
return;
}
// Обработка выбора пунктов меню
if (text.Equals("Добавить подписку", StringComparison.OrdinalIgnoreCase))
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "Отправьте одно или несколько ключевых слов для подписки (через пробел, запятую или с новой строки).",
cancellationToken: cancellationToken);
_chatStates[chatId] = ConversationState.AwaitingAddKeyword;
return;
}
if (text.Equals("Удалить подписку", StringComparison.OrdinalIgnoreCase))
{
await ShowDeleteMenuAsync(message.Chat.Id, chatId, cancellationToken);
_chatStates[chatId] = ConversationState.None;
return;
}
if (text.Equals("Список подписок", StringComparison.OrdinalIgnoreCase))
{
await ShowSubscriptionsListAsync(message.Chat.Id, chatId, cancellationToken);
_chatStates[chatId] = ConversationState.None;
return;
}
// Обработка состояний диалога
_chatStates.TryGetValue(chatId, out var state);
if (state == ConversationState.AwaitingAddKeyword)
{
var separators = new[] { ',', ';', '\n', '\r', '\t', ' ' };
var keywords = text
.Split(separators, StringSplitOptions.RemoveEmptyEntries)
.Select(k => k.Trim())
.Where(k => k.Length > 0)
.ToList();
if (keywords.Count == 0)
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "Не нашёл ни одного ключевого слова. Попробуйте ещё раз или нажмите /menu.",
cancellationToken: cancellationToken);
return;
}
foreach (var keyword in keywords)
{
await _subscriptionRepository.AddSubscriptionAsync(chatId, keyword, cancellationToken);
}
_chatStates[chatId] = ConversationState.None;
var confirmation = new StringBuilder();
confirmation.AppendLine("Добавлены правила подписки по ключевым словам:");
confirmation.AppendLine(string.Join(", ", keywords));
await botClient.SendMessage(
chatId: message.Chat.Id,
text: confirmation.ToString(),
cancellationToken: cancellationToken);
Logger.Info(
"Для чата {ChatId} добавлены правила подписки по ключевым словам: {Keywords}",
chatId,
string.Join(", ", keywords));
return;
}
// Если состояние не распознано — просто напоминаем про меню
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "Используйте команду /menu для управления подписками.",
cancellationToken: cancellationToken);
}
private async Task HandleCallbackQueryAsync(ITelegramBotClient botClient, CallbackQuery callbackQuery, CancellationToken cancellationToken)
{
if (callbackQuery.Data is null)
{
return;
}
if (callbackQuery.Data.StartsWith("del:", StringComparison.Ordinal))
{
var idPart = callbackQuery.Data["del:".Length..];
if (!long.TryParse(idPart, CultureInfo.InvariantCulture, out var id))
{
return;
}
await _subscriptionRepository.DeleteSubscriptionAsync(id, cancellationToken);
await botClient.AnswerCallbackQuery(
callbackQueryId: callbackQuery.Id,
text: "Подписка удалена.",
cancellationToken: cancellationToken);
if (callbackQuery.Message is not null)
{
var chatId = callbackQuery.Message.Chat.Id;
var chatIdString = chatId.ToString(CultureInfo.InvariantCulture);
await ShowDeleteMenuAsync(chatId, chatIdString, cancellationToken);
}
}
}
private Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, CancellationToken cancellationToken)
{
var errorMessage = exception switch
{
ApiRequestException apiRequestException =>
$"Ошибка Telegram API: [{apiRequestException.ErrorCode}] {apiRequestException.Message}",
_ => exception.ToString()
};
Logger.Error("Ошибка Telegram-бота: {Error}", errorMessage);
return Task.CompletedTask;
}
private async Task SendDealAsync(Deal deal, string chatId, string? selectionRule, CancellationToken cancellationToken)
{
var textBuilder = new StringBuilder();
textBuilder.AppendLine($"🔥 {deal.Title}");
if (!string.IsNullOrWhiteSpace(selectionRule))
{
textBuilder.AppendLine($"Правило отбора: {selectionRule}");
textBuilder.AppendLine();
}
if (deal.CurrentPrice is not null)
{
textBuilder.AppendLine($"Цена: {deal.CurrentPrice:0.##} ₽");
}
if (deal.RetailPrice is not null && deal.RetailPrice > 0 && deal.CurrentPrice is not null)
{
textBuilder.AppendLine($"Старая цена: {deal.RetailPrice:0.##} ₽");
}
if (deal.PercentOff is not null)
{
textBuilder.AppendLine($"Скидка: {deal.PercentOff}%");
}
textBuilder.AppendLine();
textBuilder.AppendLine($"Магазин: {deal.StoreName}");
textBuilder.AppendLine();
textBuilder.AppendLine($"Товар в магазине: {deal.DealUrl}");
if (!string.IsNullOrEmpty(deal.Permalink))
{
textBuilder.AppendLine($"Открыть на pepper: https://www.pepper.ru/deals/{deal.Permalink}");
}
try
{
var text = textBuilder.ToString();
if (!string.IsNullOrWhiteSpace(deal.ImageMedium)
&& Uri.TryCreate(deal.ImageMedium, UriKind.Absolute, out var imageUri))
{
await _botClient.SendPhoto(
chatId: chatId,
photo: InputFile.FromUri(imageUri),
caption: text,
cancellationToken: cancellationToken);
return;
}
await _botClient.SendMessage(
chatId: chatId,
text: text,
cancellationToken: cancellationToken);
}
catch (ApiRequestException ex)
{
Logger.Warn(
ex,
"Не удалось отправить сообщение в Telegram (чат {ChatId}): [{Code}] {Message}",
chatId,
ex.ErrorCode,
ex.Message);
// Фолбэк: если фото не отправилось (например, Telegram не смог скачать URL),
// пробуем хотя бы текст.
try
{
await _botClient.SendMessage(
chatId: chatId,
text: textBuilder.ToString(),
cancellationToken: cancellationToken);
}
catch (ApiRequestException fallbackEx)
{
Logger.Warn(
fallbackEx,
"Не удалось отправить фолбэк-текст в Telegram (чат {ChatId}): [{Code}] {Message}",
chatId,
fallbackEx.ErrorCode,
fallbackEx.Message);
}
}
}
private Task SendMainMenuAsync(ChatId chatId, CancellationToken cancellationToken)
{
var keyboard = new ReplyKeyboardMarkup(new[]
{
new[] { new KeyboardButton("Добавить подписку") },
new[] { new KeyboardButton("Удалить подписку") },
new[] { new KeyboardButton("Список подписок") }
})
{
ResizeKeyboard = true,
OneTimeKeyboard = false
};
return _botClient.SendMessage(
chatId: chatId,
text: "Меню управления подписками:",
replyMarkup: keyboard,
cancellationToken: cancellationToken);
}
private async Task ShowSubscriptionsListAsync(ChatId chatId, string chatIdString, CancellationToken cancellationToken)
{
var subs = await _subscriptionRepository.GetByChatAsync(chatIdString, cancellationToken);
if (subs.Count == 0)
{
await _botClient.SendMessage(
chatId: chatId,
text: "У вас пока нет подписок.",
cancellationToken: cancellationToken);
return;
}
var sb = new StringBuilder();
sb.AppendLine("Ваши подписки:");
foreach (var sub in subs)
{
sb.AppendLine($"• [{sub.Id}] {sub.Keywords}");
}
await _botClient.SendMessage(
chatId: chatId,
text: sb.ToString(),
cancellationToken: cancellationToken);
}
private async Task ShowDeleteMenuAsync(ChatId chatId, string chatIdString, CancellationToken cancellationToken)
{
var subs = await _subscriptionRepository.GetByChatAsync(chatIdString, cancellationToken);
if (subs.Count == 0)
{
await _botClient.SendMessage(
chatId: chatId,
text: "Подписок для удаления не найдено.",
cancellationToken: cancellationToken);
return;
}
var buttons = subs
.Select(s => InlineKeyboardButton.WithCallbackData(
text: s.Keywords,
callbackData: $"del:{s.Id}"))
.Chunk(2)
.Select(chunk => chunk.ToArray())
.ToArray();
var keyboard = new InlineKeyboardMarkup(buttons);
await _botClient.SendMessage(
chatId: chatId,
text: "Выберите подписку для удаления:",
replyMarkup: keyboard,
cancellationToken: cancellationToken);
}
}