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