daily_digest/Worker.cs

283 lines
19 KiB
C#
Raw Normal View History

using DailyDigestWorker.Configuration; // Наша конфигурация
using DailyDigestWorker.Services; // Наши сервисы
using Microsoft.Extensions.Options; // Для IOptions
using System.Globalization; // Для TimeSpan.ParseExact и других настроек культуры
using Telegram.Bot.Types.Enums; // Для ParseMode
namespace DailyDigestWorker
{
2025-04-12 10:20:46 +07:00
// Структура для хранения распарсенного расписания (внутренняя)
internal readonly record struct ParsedScheduleEntry(TimeSpan TimeLocal, string ChannelUsername, DateTime NextRunTargetZone);
public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
2025-04-12 10:20:46 +07:00
// Храним список пар (Время в целевой зоне, Имя канала)
private readonly List<(TimeSpan TimeLocal, string ChannelUsername)> _schedules;
2025-04-12 01:35:29 +07:00
private readonly TimeZoneInfo _targetTimeZone; // Целевой часовой пояс пользователя
private readonly IServiceProvider _serviceProvider;
public Worker(ILogger<Worker> logger,
2025-04-12 01:35:29 +07:00
IOptions<SchedulingSettings> schedulingSettings, // Используем обновленный SchedulingSettings
IServiceProvider serviceProvider)
{
_logger = logger;
_serviceProvider = serviceProvider;
2025-04-12 10:20:46 +07:00
_schedules = new List<(TimeSpan TimeLocal, string ChannelUsername)>(); // Инициализируем список
2025-04-12 01:35:29 +07:00
var settings = schedulingSettings.Value ?? new SchedulingSettings();
2025-04-12 10:20:46 +07:00
var scheduleEntries = settings.Schedules; // Получаем новый список ScheduleEntry
string targetTimeZoneId = settings.TargetTimeZoneId;
2025-04-12 01:35:29 +07:00
// 1. Определяем целевой часовой пояс
try
{
_targetTimeZone = TimeZoneInfo.FindSystemTimeZoneById(targetTimeZoneId);
_logger.LogInformation("Целевой часовой пояс установлен: {TimeZoneId} ({DisplayName})", _targetTimeZone.Id, _targetTimeZone.DisplayName);
}
catch (TimeZoneNotFoundException)
{
_logger.LogError("Часовой пояс с ID '{TimeZoneId}' не найден в системе! Будет использовано локальное время сервера (TimeZoneInfo.Local).", targetTimeZoneId);
2025-04-12 10:20:46 +07:00
_targetTimeZone = TimeZoneInfo.Local;
2025-04-12 01:35:29 +07:00
}
catch (InvalidTimeZoneException ex)
{
_logger.LogError(ex, "Ошибка при загрузке данных часового пояса '{TimeZoneId}'. Будет использовано локальное время сервера.", targetTimeZoneId);
_targetTimeZone = TimeZoneInfo.Local;
}
2025-04-12 10:20:46 +07:00
catch (Exception ex)
2025-04-12 01:35:29 +07:00
{
_logger.LogError(ex, "Неожиданная ошибка при определении часового пояса '{TimeZoneId}'. Будет использовано локальное время сервера.", targetTimeZoneId);
_targetTimeZone = TimeZoneInfo.Local;
}
2025-04-12 10:20:46 +07:00
// 2. Парсим новое расписание из конфигурации
if (scheduleEntries != null)
{
2025-04-12 10:20:46 +07:00
foreach (var entry in scheduleEntries)
{
2025-04-12 10:20:46 +07:00
if (entry != null &&
!string.IsNullOrWhiteSpace(entry.ChannelUsername) &&
TimeSpan.TryParseExact(entry.TimeLocal?.Trim(), @"hh\:mm\:ss", CultureInfo.InvariantCulture, out var timeSpan))
{
2025-04-12 10:20:46 +07:00
// Добавляем валидную пару (время, канал) в список
_schedules.Add((timeSpan, entry.ChannelUsername.Trim())); // Trim на случай лишних пробелов
}
else
{
2025-04-12 10:20:46 +07:00
_logger.LogWarning("Некорректная или неполная запись в расписании Scheduling:Schedules: {@ScheduleEntry}. Запись проигнорирована.", entry);
}
}
2025-04-12 10:20:46 +07:00
// Убираем дубликаты и сортируем по времени
_schedules = _schedules
.GroupBy(s => s.TimeLocal) // Группируем по времени, чтобы избежать дублей времени
.Select(g => g.First()) // Берем первую запись для каждого времени
.OrderBy(s => s.TimeLocal) // Сортируем по времени
.ToList();
}
2025-04-12 10:20:46 +07:00
// 3. Проверка и логирование расписания
if (!_schedules.Any())
{
2025-04-12 10:20:46 +07:00
_logger.LogError("В конфигурации не указано ни одного корректного расписания (Scheduling:Schedules)! Worker не сможет автоматически запускаться.");
}
2025-04-12 10:20:46 +07:00
_logger.LogInformation("Worker инициализирован. Расписание запусков (по времени {TargetTimeZoneId}):", _targetTimeZone.Id);
if (_schedules.Any())
{
foreach (var schedule in _schedules)
{
_logger.LogInformation(" - {Time} -> Канал: {Channel}", schedule.TimeLocal.ToString(@"hh\:mm\:ss"), schedule.ChannelUsername);
}
}
else
{
_logger.LogInformation(" - НЕТ ЗАПЛАНИРОВАННЫХ ЗАПУСКОВ");
}
}
// Основной метод, который выполняется в фоне
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
2025-04-12 10:20:46 +07:00
_logger.LogInformation("Worker запущен в: {time} UTC", DateTimeOffset.UtcNow);
2025-04-12 10:20:46 +07:00
if (!_schedules.Any())
{
2025-04-12 10:20:46 +07:00
_logger.LogError("Нет сконфигурированных расписаний запуска. Worker завершает работу.");
return; // Останавливаем выполнение, если нет расписаний
}
while (!stoppingToken.IsCancellationRequested)
{
2025-04-12 10:20:46 +07:00
// Рассчитываем задержку до ближайшего запуска И получаем задание для этого запуска
(TimeSpan delay, string channelForNextRun, DateTime nextRunTargetZone) = CalculateDelayAndNextSchedule(_schedules, _targetTimeZone);
2025-04-12 10:20:46 +07:00
_logger.LogInformation("Следующий запуск дайджеста из канала '{Channel}' запланирован через: {Delay} (в {TargetTimeLocal} {TargetZoneId})",
channelForNextRun, delay, nextRunTargetZone.ToString("yyyy-MM-dd HH:mm:ss"), _targetTimeZone.Id);
try
{
2025-04-12 10:20:46 +07:00
// Ожидаем рассчитанную задержку
await Task.Delay(delay, stoppingToken);
}
catch (OperationCanceledException)
{
_logger.LogInformation("Запрос на остановку получен во время ожидания.");
break; // Выходим из цикла while
}
2025-04-12 01:35:29 +07:00
if (stoppingToken.IsCancellationRequested) break; // Проверяем еще раз
2025-04-12 10:20:46 +07:00
// --- Определяем задание для ТЕКУЩЕГО запуска ---
// Пересчитываем, чтобы получить актуальное задание на момент пробуждения
2025-04-12 10:46:39 +07:00
string currentChannelUsername = channelForNextRun;
2025-04-12 10:20:46 +07:00
_logger.LogInformation("--- Начало цикла обработки дайджеста для канала: [{ChannelUsername}] ({Time} UTC) ---", currentChannelUsername, DateTimeOffset.UtcNow);
// --- Выполнение основной задачи ---
using (var scope = _serviceProvider.CreateScope())
{
2025-04-12 10:20:46 +07:00
// Получаем все необходимые сервисы
var currencyService = scope.ServiceProvider.GetRequiredService<ICurrencyService>();
var cryptoService = scope.ServiceProvider.GetRequiredService<ICryptoService>();
var weatherService = scope.ServiceProvider.GetRequiredService<IWeatherService>();
var digestBuilder = scope.ServiceProvider.GetRequiredService<IDigestBuilderService>();
var telegramSender = scope.ServiceProvider.GetRequiredService<ITelegramBotSender>();
var newsReaderService = scope.ServiceProvider.GetRequiredService<ITelegramChannelReader>();
var summarizationService = scope.ServiceProvider.GetRequiredService<ISummarizationService>();
2025-04-12 10:20:46 +07:00
const int NewsMaxAgeHours = 24; // Можно сделать динамическим
2025-04-12 01:35:29 +07:00
const int NewsFetchLimit = 50;
string? newsSummary = null;
try
{
// --- 1. Сбор данных ---
2025-04-12 10:20:46 +07:00
_logger.LogInformation("1. Начало сбора данных...");
var currencyTask = currencyService.GetCurrencyRatesAsync(stoppingToken);
var cryptoTask = cryptoService.GetCryptoRatesAsync(stoppingToken);
var weatherTask = weatherService.GetWeatherAsync(stoppingToken);
2025-04-12 10:20:46 +07:00
// Передаем имя канала, актуальное для ЭТОГО запуска
var newsTask = newsReaderService.GetRecentNewsAsync(currentChannelUsername, NewsMaxAgeHours, NewsFetchLimit, stoppingToken);
await Task.WhenAll(currencyTask, cryptoTask, weatherTask, newsTask);
var currencyData = await currencyTask;
var cryptoData = await cryptoTask;
var weatherData = await weatherTask;
var rawNewsTexts = await newsTask;
2025-04-12 10:20:46 +07:00
_logger.LogInformation("1. Сбор данных завершен.");
2025-04-12 10:20:46 +07:00
// --- 1.5 Суммаризация ---
if (rawNewsTexts != null && rawNewsTexts.Any())
{
2025-04-12 10:20:46 +07:00
_logger.LogInformation("1.5 Получено {NewsCount} текстов из '{Channel}'. Запуск суммаризации...", rawNewsTexts.Count, currentChannelUsername);
try
{
string combinedNewsText = string.Join("\n\n---\n\n", rawNewsTexts);
2025-04-12 10:20:46 +07:00
// В будущем можно передать currentChannelUsername в SummarizeTextAsync для контекста промпта
newsSummary = await summarizationService.SummarizeTextAsync(combinedNewsText, stoppingToken);
2025-04-12 10:20:46 +07:00
if (newsSummary != null) { _logger.LogInformation("1.5 Суммаризация успешна."); }
else { _logger.LogWarning("1.5 Сервис суммаризации вернул null."); }
}
2025-04-12 10:20:46 +07:00
catch (OperationCanceledException) { _logger.LogInformation("1.5 Операция суммаризации отменена."); }
catch (Exception ex) { _logger.LogError(ex, "1.5 Ошибка во время вызова сервиса суммаризации."); }
}
else
{
2025-04-12 10:20:46 +07:00
_logger.LogInformation("1.5 Нет текстов новостей для суммаризации из канала '{Channel}'.", currentChannelUsername);
}
2025-04-12 01:35:29 +07:00
// --- 2. Формирование текста ---
2025-04-12 10:20:46 +07:00
_logger.LogInformation("2. Формирование текста дайджеста...");
// Можно передать currentChannelUsername для добавления в заголовок дайджеста
string digestText = digestBuilder.BuildDigestText(currencyData, cryptoData, weatherData, newsSummary);
2025-04-12 10:20:46 +07:00
_logger.LogDebug("2. Сформированный текст получен от DigestBuilder.");
2025-04-12 01:35:29 +07:00
// --- 3. Отправка ---
if (!string.IsNullOrWhiteSpace(digestText))
{
2025-04-12 10:20:46 +07:00
_logger.LogInformation("3. Отправка дайджеста...");
bool sent = await telegramSender.SendMessageAsync(digestText, ParseMode.Markdown, stoppingToken);
2025-04-12 10:20:46 +07:00
if (!sent) { _logger.LogWarning("3. Не удалось инициировать отправку дайджеста."); }
}
2025-04-12 10:20:46 +07:00
else { _logger.LogWarning("3. Текст дайджеста пуст, отправка не производится."); }
}
catch (OperationCanceledException)
{
_logger.LogInformation("Запрос на остановку получен во время выполнения основной задачи.");
2025-04-12 10:20:46 +07:00
break; // Выходим из while
}
catch (Exception ex)
2025-04-12 01:35:29 +07:00
{ // Ловим КРИТИЧЕСКИЕ ошибки цикла задачи
2025-04-12 10:20:46 +07:00
_logger.LogError(ex, "Произошла КРИТИЧЕСКАЯ ошибка во время выполнения задачи создания дайджеста для канала {Channel}.", currentChannelUsername);
try { await telegramSender.SendErrorMessageAsync($"Критическая ошибка в Worker для канала {currentChannelUsername}: {ex.Message}", CancellationToken.None); }
2025-04-12 01:35:29 +07:00
catch (Exception sendEx) { _logger.LogError(sendEx, "Не удалось отправить сообщение о КРИТИЧЕСКОЙ ошибке."); }
}
} // Конец scope
2025-04-12 10:20:46 +07:00
_logger.LogInformation("--- Завершение цикла обработки дайджеста для канала: [{ChannelUsername}] ---", currentChannelUsername);
2025-04-12 10:20:46 +07:00
// Короткая пауза после выполнения
2025-04-12 01:35:29 +07:00
try { await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); }
catch (OperationCanceledException) { break; }
} // Конец while
_logger.LogInformation("Worker останавливается.");
}
2025-04-12 10:20:46 +07:00
// Метод расчета задержки И получения СЛЕДУЮЩЕГО задания из расписания
private (TimeSpan Delay, string ChannelUsername, DateTime NextRunTargetZone) CalculateDelayAndNextSchedule(
List<(TimeSpan TimeLocal, string ChannelUsername)> schedules, TimeZoneInfo targetZone)
{
2025-04-12 10:20:46 +07:00
// Получаем текущее время в целевом часовом поясе
2025-04-12 01:35:29 +07:00
DateTime targetZoneNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, targetZone);
DateTime targetZoneToday = targetZoneNow.Date;
TimeSpan targetZoneCurrentTimeOfDay = targetZoneNow.TimeOfDay;
2025-04-12 10:20:46 +07:00
_logger.LogDebug("Расчет след. запуска: Текущее время в зоне {TargetZoneId}: {TargetNow:yyyy-MM-dd HH:mm:ss.fff}", targetZone.Id, targetZoneNow);
2025-04-12 01:35:29 +07:00
2025-04-12 10:20:46 +07:00
// Ищем первое расписание на СЕГОДНЯ (в целевой зоне), время которого >= текущего времени
var nextScheduleToday = schedules
.Where(s => s.TimeLocal >= targetZoneCurrentTimeOfDay)
.Cast<(TimeSpan TimeLocal, string ChannelUsername)?>()
.FirstOrDefault();
2025-04-12 10:20:46 +07:00
(TimeSpan TimeLocal, string ChannelUsername) targetSchedule;
2025-04-12 01:35:29 +07:00
DateTime nextRunTargetZoneDateTime; // Время следующего запуска в целевой зоне
2025-04-12 10:20:46 +07:00
if (nextScheduleToday.HasValue)
{
2025-04-12 10:20:46 +07:00
// Нашли запуск на сегодня
targetSchedule = nextScheduleToday.Value;
nextRunTargetZoneDateTime = targetZoneToday + targetSchedule.TimeLocal;
_logger.LogDebug("Расчет след. запуска: Найден запуск на сегодня ({Channel}) в {RunTime:HH:mm:ss}", targetSchedule.ChannelUsername, nextRunTargetZoneDateTime);
}
else
{
2025-04-12 10:20:46 +07:00
// Все запуски сегодня прошли, берем первый запуск из списка на ЗАВТРА
targetSchedule = schedules.First(); // Гарантированно есть и отсортировано
nextRunTargetZoneDateTime = targetZoneToday.AddDays(1) + targetSchedule.TimeLocal;
_logger.LogDebug("Расчет след. запуска: Запуски на сегодня прошли. Следующий завтра ({Channel}) в {RunTime:HH:mm:ss}", targetSchedule.ChannelUsername, nextRunTargetZoneDateTime);
}
2025-04-12 10:20:46 +07:00
// Переводим время следующего запуска из целевой зоны в UTC
2025-04-12 01:35:29 +07:00
DateTime nextRunUtc = TimeZoneInfo.ConvertTimeToUtc(nextRunTargetZoneDateTime, targetZone);
// Рассчитываем задержку от ТЕКУЩЕГО времени UTC
TimeSpan delay = nextRunUtc - DateTime.UtcNow;
2025-04-12 10:20:46 +07:00
_logger.LogDebug("Расчет след. запуска: Время UTC {NextRunUtc:yyyy-MM-dd HH:mm:ss}Z. Задержка: {Delay}", nextRunUtc, delay);
2025-04-12 01:35:29 +07:00
2025-04-12 10:20:46 +07:00
// Возвращаем задержку (не меньше нуля), имя канала для этого запуска и время запуска в целевой зоне
return (delay < TimeSpan.Zero ? TimeSpan.Zero : delay, targetSchedule.ChannelUsername, nextRunTargetZoneDateTime);
}
public override Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Worker StopAsync вызван.");
return base.StopAsync(cancellationToken);
}
}
}