using DailyDigestWorker.Configuration; // Наша конфигурация using DailyDigestWorker.Services; // Наши сервисы using Microsoft.Extensions.Options; // Для IOptions using System.Globalization; // Для TimeSpan.ParseExact и других настроек культуры using Telegram.Bot.Types.Enums; // Для ParseMode namespace DailyDigestWorker { public class Worker : BackgroundService { private readonly ILogger _logger; // Список времен запуска в формате TimeSpan (представляют ЛОКАЛЬНОЕ время ЦЕЛЕВОГО часового пояса) private readonly List _runTimesLocal; private readonly TimeZoneInfo _targetTimeZone; // Целевой часовой пояс пользователя private readonly IServiceProvider _serviceProvider; public Worker(ILogger logger, IOptions schedulingSettings, // Используем обновленный SchedulingSettings IServiceProvider serviceProvider) { _logger = logger; _serviceProvider = serviceProvider; _runTimesLocal = new List(); // Инициализируем список // Получаем настройки планировщика (или используем дефолтные, если секция отсутствует) var settings = schedulingSettings.Value ?? new SchedulingSettings(); var timeStrings = settings.RunAtTimesLocal; // Список строк времени string targetTimeZoneId = settings.TargetTimeZoneId; // ID часового пояса // 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) // Ловим другие возможные ошибки при работе с TimeZoneInfo { _logger.LogError(ex, "Неожиданная ошибка при определении часового пояса '{TimeZoneId}'. Будет использовано локальное время сервера.", targetTimeZoneId); _targetTimeZone = TimeZoneInfo.Local; } // 2. Парсим ВРЕМЕНА ЗАПУСКА (они всегда в целевом часовом поясе) if (timeStrings != null) { foreach (var timeStr in timeStrings) { // Используем ParseExact для строгого формата "HH:mm:ss" if (TimeSpan.TryParseExact(timeStr?.Trim(), @"hh\:mm\:ss", CultureInfo.InvariantCulture, out var timeSpan)) { _runTimesLocal.Add(timeSpan); } else { _logger.LogWarning("Не удалось распознать время '{TimeString}' в настройках Scheduling:RunAtTimesLocal. Используйте формат HH:mm:ss.", timeStr); } } // Убираем дубликаты и сортируем _runTimesLocal = _runTimesLocal.Distinct().OrderBy(t => t).ToList(); } // 3. Проверка и логирование времен запуска if (!_runTimesLocal.Any()) { _logger.LogError("В конфигурации не указано ни одного корректного времени запуска (RunAtTimesLocal)! Worker не сможет автоматически запускаться."); // Здесь НЕТ добавления времени по умолчанию, чтобы требовать явную конфигурацию } _logger.LogInformation("Worker инициализирован. Дайджест будет запускаться ежедневно в (по времени {TargetTimeZoneId}): {RunTimes}", _targetTimeZone.Id, _runTimesLocal.Any() ? string.Join(", ", _runTimesLocal.Select(t => t.ToString(@"hh\:mm\:ss"))) : "НЕТ ВРЕМЕН ЗАПУСКА"); } // Основной метод, который выполняется в фоне protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("Worker запущен в: {time} UTC", DateTimeOffset.UtcNow); // Логгируем UTC время запуска if (!_runTimesLocal.Any()) { _logger.LogError("Нет сконфигурированных времен запуска. Worker завершает работу."); return; // Останавливаем выполнение, если нет времен } while (!stoppingToken.IsCancellationRequested) { // Рассчитываем задержку до ближайшего времени запуска в целевом часовом поясе TimeSpan delay = CalculateDelayUntilNearestRunInTargetZone(_runTimesLocal, _targetTimeZone); // Логируем время следующего запуска в UTC и в целевой зоне var nextRunUtc = DateTime.UtcNow.Add(delay); var nextRunTargetZone = TimeZoneInfo.ConvertTimeFromUtc(nextRunUtc, _targetTimeZone); _logger.LogInformation("Следующий запуск дайджеста запланирован через: {Delay} (в {TargetTimeLocal} {TargetZoneId})", 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; // Проверяем еще раз // --- Выполнение основной задачи --- _logger.LogInformation("Запуск процесса создания дайджеста в: {time} UTC", 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("Начало сбора данных для дайджеста..."); var currencyTask = currencyService.GetCurrencyRatesAsync(stoppingToken); var cryptoTask = cryptoService.GetCryptoRatesAsync(stoppingToken); var weatherTask = weatherService.GetWeatherAsync(stoppingToken); var newsTask = newsReaderService.GetRecentNewsAsync(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.5 Генерация саммари --- if (rawNewsTexts != null && rawNewsTexts.Any()) { _logger.LogInformation("Получено {NewsCount} текстов новостей. Запуск суммаризации...", rawNewsTexts.Count); try { string combinedNewsText = string.Join("\n\n---\n\n", rawNewsTexts); newsSummary = await summarizationService.SummarizeTextAsync(combinedNewsText, stoppingToken); if (newsSummary != null) { _logger.LogInformation("Саммари новостей успешно получено."); _logger.LogDebug("Текст саммари:\n{NewsSummary}", newsSummary); } else { _logger.LogWarning("Сервис суммаризации вернул null."); } } catch (OperationCanceledException) { _logger.LogInformation("Операция суммаризации была отменена."); } catch (Exception ex) { _logger.LogError(ex, "Ошибка во время вызова сервиса суммаризации."); } } else { _logger.LogInformation("Нет текстов новостей для суммаризации."); } // --- 2. Формирование текста --- _logger.LogInformation("Формирование текста дайджеста..."); string digestText = digestBuilder.BuildDigestText(currencyData, cryptoData, weatherData, newsSummary); _logger.LogDebug("Сформированный текст получен от DigestBuilder."); // --- 3. Отправка --- if (!string.IsNullOrWhiteSpace(digestText)) { _logger.LogInformation("Отправка дайджеста..."); bool sent = await telegramSender.SendMessageAsync(digestText, ParseMode.Markdown, stoppingToken); if (!sent) { _logger.LogWarning("Не удалось инициировать отправку дайджеста."); } } else { _logger.LogWarning("Текст дайджеста пуст, отправка не производится."); } } catch (OperationCanceledException) { _logger.LogInformation("Запрос на остановку получен во время выполнения основной задачи."); break; } catch (Exception ex) { // Ловим КРИТИЧЕСКИЕ ошибки цикла задачи _logger.LogError(ex, "Произошла КРИТИЧЕСКАЯ ошибка во время выполнения задачи создания дайджеста."); try { await telegramSender.SendErrorMessageAsync($"Критическая ошибка в Worker: {ex.Message}", CancellationToken.None); } catch (Exception sendEx) { _logger.LogError(sendEx, "Не удалось отправить сообщение о КРИТИЧЕСКОЙ ошибке."); } // Не делаем Delay, чтобы быстрее перейти к следующему плановому запуску } } // Конец scope _logger.LogInformation("Процесс создания дайджеста завершен. Расчет следующего запуска."); // Короткая пауза после выполнения, чтобы не запустить случайно дважды в одну секунду try { await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); } catch (OperationCanceledException) { break; } } // Конец while _logger.LogInformation("Worker останавливается."); } // Метод для расчета задержки до ближайшего времени запуска в целевом часовом поясе private TimeSpan CalculateDelayUntilNearestRunInTargetZone(List runTimesLocal, 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}", targetZone.Id, targetZoneNow); // Ищем ближайшее время запуска СЕГОДНЯ (в целевой зоне), которое >= текущего времени TimeSpan? nextRunTimeToday = runTimesLocal .Where(t => t >= targetZoneCurrentTimeOfDay) .Cast() .FirstOrDefault(); // Используем FirstOrDefault, т.к. список отсортирован DateTime nextRunTargetZoneDateTime; // Время следующего запуска в целевой зоне if (nextRunTimeToday.HasValue) { // Планируем на сегодня (в целевой зоне) nextRunTargetZoneDateTime = targetZoneToday + nextRunTimeToday.Value; _logger.LogDebug("Ближайшее время запуска сегодня (в целевой зоне): {RunTime:yyyy-MM-dd HH:mm:ss}", nextRunTargetZoneDateTime); } else { // Планируем на ЗАВТРА (в целевой зоне), используя самое раннее время var earliestTime = runTimesLocal.First(); // Гарантированно есть и отсортировано nextRunTargetZoneDateTime = targetZoneToday.AddDays(1) + earliestTime; _logger.LogDebug("Все времена запуска сегодня (в целевой зоне) прошли. Ближайшее время завтра: {RunTime:yyyy-MM-dd HH:mm:ss}", 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", nextRunUtc); // Если задержка отрицательная (уже опоздали), возвращаем Zero для немедленного запуска. return delay < TimeSpan.Zero ? TimeSpan.Zero : delay; } public override Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("Worker StopAsync вызван."); return base.StopAsync(cancellationToken); } } }