diff --git a/Configuration/SchedulingSettings.cs b/Configuration/SchedulingSettings.cs index 44999d5..39cafca 100644 --- a/Configuration/SchedulingSettings.cs +++ b/Configuration/SchedulingSettings.cs @@ -2,7 +2,10 @@ { public class SchedulingSettings { - // Убираем значение по умолчанию. Список будет null, если не задан в конфиге. + // Список времен запуска в формате "HH:mm:ss" (это ВАШЕ локальное время) public List? RunAtTimesLocal { get; set; } + + // Идентификатор целевого часового пояса (IANA ID) + public string TargetTimeZoneId { get; set; } = "Asia/Novokuznetsk"; // Значение по умолчанию } } \ No newline at end of file diff --git a/Worker.cs b/Worker.cs index 151f8db..9ba6524 100644 --- a/Worker.cs +++ b/Worker.cs @@ -9,57 +9,84 @@ namespace DailyDigestWorker public class Worker : BackgroundService { private readonly ILogger _logger; - // Храним список времен запуска как TimeSpan - private readonly List _runTimes; + // Список времен запуска в формате TimeSpan (представляют ЛОКАЛЬНОЕ время ЦЕЛЕВОГО часового пояса) + private readonly List _runTimesLocal; + private readonly TimeZoneInfo _targetTimeZone; // Целевой часовой пояс пользователя private readonly IServiceProvider _serviceProvider; - // Внедряем зависимости через конструктор public Worker(ILogger logger, - IOptions schedulingSettings, + IOptions schedulingSettings, // Используем обновленный SchedulingSettings IServiceProvider serviceProvider) { _logger = logger; _serviceProvider = serviceProvider; - _runTimes = new List(); + _runTimesLocal = new List(); // Инициализируем список - // Проверяем наличие Value и списка перед использованием - var timeStrings = schedulingSettings.Value?.RunAtTimesLocal; // Теперь может быть null + // Получаем настройки планировщика (или используем дефолтные, если секция отсутствует) + var settings = schedulingSettings.Value ?? new SchedulingSettings(); + var timeStrings = settings.RunAtTimesLocal; // Список строк времени + string targetTimeZoneId = settings.TargetTimeZoneId; // ID часового пояса - if (timeStrings != null) // Обрабатываем только если список задан в конфиге + // 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)) { - _runTimes.Add(timeSpan); + _runTimesLocal.Add(timeSpan); } else { _logger.LogWarning("Не удалось распознать время '{TimeString}' в настройках Scheduling:RunAtTimesLocal. Используйте формат HH:mm:ss.", timeStr); } } - _runTimes = _runTimes.Distinct().OrderBy(t => t).ToList(); + // Убираем дубликаты и сортируем + _runTimesLocal = _runTimesLocal.Distinct().OrderBy(t => t).ToList(); } - // Логика обработки пустого или отсутствующего списка остается прежней - if (!_runTimes.Any()) + // 3. Проверка и логирование времен запуска + if (!_runTimesLocal.Any()) { - _logger.LogError("В конфигурации не указано ни одного корректного времени запуска! Worker не сможет автоматически запускаться."); - // Важно: убедитесь, что здесь НЕТ строки, добавляющей 8 утра по умолчанию, - // если вы хотите, чтобы бот НЕ работал без явной конфигурации времени. + _logger.LogError("В конфигурации не указано ни одного корректного времени запуска (RunAtTimesLocal)! Worker не сможет автоматически запускаться."); + // Здесь НЕТ добавления времени по умолчанию, чтобы требовать явную конфигурацию } - _logger.LogInformation("Worker инициализирован. Дайджест будет запускаться ежедневно в: {RunTimes}", - _runTimes.Any() ? string.Join(", ", _runTimes.Select(t => t.ToString(@"hh\:mm\:ss"))) : "НЕТ ВРЕМЕН ЗАПУСКА"); + _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}", DateTimeOffset.Now); + _logger.LogInformation("Worker запущен в: {time} UTC", DateTimeOffset.UtcNow); // Логгируем UTC время запуска - // Проверяем наличие времен запуска перед стартом цикла - if (!_runTimes.Any()) + if (!_runTimesLocal.Any()) { _logger.LogError("Нет сконфигурированных времен запуска. Worker завершает работу."); return; // Останавливаем выполнение, если нет времен @@ -67,16 +94,19 @@ namespace DailyDigestWorker while (!stoppingToken.IsCancellationRequested) { - // Рассчитываем задержку до ближайшего времени запуска - TimeSpan delay = CalculateDelayUntilNearestRun(_runTimes); + // Рассчитываем задержку до ближайшего времени запуска в целевом часовом поясе + TimeSpan delay = CalculateDelayUntilNearestRunInTargetZone(_runTimesLocal, _targetTimeZone); - _logger.LogInformation("Следующий запуск дайджеста запланирован через: {Delay} (в {TargetTime})", - delay, DateTime.Now.Add(delay).ToString("yyyy-MM-dd HH:mm:ss")); + // Логируем время следующего запуска в 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 { - // Ожидаем до следующего времени запуска, прерывая ожидание, если придет сигнал остановки - // Если задержка 0 или меньше (опоздали), Task.Delay корректно обработает это и не будет ждать. + // Ожидаем до следующего времени запуска await Task.Delay(delay, stoppingToken); } catch (OperationCanceledException) @@ -85,14 +115,13 @@ namespace DailyDigestWorker break; // Выходим из цикла while } - // Проверяем еще раз перед выполнением задачи, на случай если остановка пришла точно в момент срабатывания - if (stoppingToken.IsCancellationRequested) break; + if (stoppingToken.IsCancellationRequested) break; // Проверяем еще раз // --- Выполнение основной задачи --- - _logger.LogInformation("Запуск процесса создания дайджеста в: {time}", DateTimeOffset.Now); + _logger.LogInformation("Запуск процесса создания дайджеста в: {time} UTC", DateTimeOffset.UtcNow); using (var scope = _serviceProvider.CreateScope()) { - // --- Получаем ВСЕ необходимые сервисы в начале scope --- + // --- Получаем сервисы --- var currencyService = scope.ServiceProvider.GetRequiredService(); var cryptoService = scope.ServiceProvider.GetRequiredService(); var weatherService = scope.ServiceProvider.GetRequiredService(); @@ -101,11 +130,9 @@ namespace DailyDigestWorker var newsReaderService = scope.ServiceProvider.GetRequiredService(); var summarizationService = scope.ServiceProvider.GetRequiredService(); - // Константы для чтения новостей (можно вынести в конфиг) const int NewsMaxAgeHours = 24; - const int NewsFetchLimit = 50; // Сколько последних сообщений проверить - - string? newsSummary = null; // Инициализируем переменную для саммари + const int NewsFetchLimit = 50; + string? newsSummary = null; try { @@ -122,10 +149,9 @@ namespace DailyDigestWorker var cryptoData = await cryptoTask; var weatherData = await weatherTask; var rawNewsTexts = await newsTask; - _logger.LogInformation("Сбор всех базовых данных завершен."); - // --- 1.5 Генерация саммари новостей (если есть новости) --- + // --- 1.5 Генерация саммари --- if (rawNewsTexts != null && rawNewsTexts.Any()) { _logger.LogInformation("Получено {NewsCount} текстов новостей. Запуск суммаризации...", rawNewsTexts.Count); @@ -133,7 +159,6 @@ namespace DailyDigestWorker { string combinedNewsText = string.Join("\n\n---\n\n", rawNewsTexts); newsSummary = await summarizationService.SummarizeTextAsync(combinedNewsText, stoppingToken); - if (newsSummary != null) { _logger.LogInformation("Саммари новостей успешно получено."); @@ -144,123 +169,100 @@ namespace DailyDigestWorker _logger.LogWarning("Сервис суммаризации вернул null."); } } - catch (OperationCanceledException) - { - _logger.LogInformation("Операция суммаризации была отменена."); - } - catch (Exception ex) - { - _logger.LogError(ex, "Ошибка во время вызова сервиса суммаризации."); - } + catch (OperationCanceledException) { _logger.LogInformation("Операция суммаризации была отменена."); } + catch (Exception ex) { _logger.LogError(ex, "Ошибка во время вызова сервиса суммаризации."); } } else { _logger.LogInformation("Нет текстов новостей для суммаризации."); } - // --- 2. Формирование текста дайджеста --- + // --- 2. Формирование текста --- _logger.LogInformation("Формирование текста дайджеста..."); string digestText = digestBuilder.BuildDigestText(currencyData, cryptoData, weatherData, newsSummary); _logger.LogDebug("Сформированный текст получен от DigestBuilder."); - // --- 3. Отправка дайджеста --- + // --- 3. Отправка --- if (!string.IsNullOrWhiteSpace(digestText)) { _logger.LogInformation("Отправка дайджеста..."); bool sent = await telegramSender.SendMessageAsync(digestText, ParseMode.Markdown, stoppingToken); - if (!sent) - { - _logger.LogWarning("Не удалось инициировать отправку дайджеста."); - } - } - else - { - _logger.LogWarning("Текст дайджеста пуст, отправка не производится."); + if (!sent) { _logger.LogWarning("Не удалось инициировать отправку дайджеста."); } } + else { _logger.LogWarning("Текст дайджеста пуст, отправка не производится."); } } catch (OperationCanceledException) { _logger.LogInformation("Запрос на остановку получен во время выполнения основной задачи."); - break; // Выходим из while + break; } catch (Exception ex) - { // Ловим ошибки на уровне всего цикла задачи + { // Ловим КРИТИЧЕСКИЕ ошибки цикла задачи _logger.LogError(ex, "Произошла КРИТИЧЕСКАЯ ошибка во время выполнения задачи создания дайджеста."); - try - { - await telegramSender.SendErrorMessageAsync($"Критическая ошибка в Worker: {ex.Message}", CancellationToken.None); - } - catch (Exception sendEx) - { - _logger.LogError(sendEx, "Не удалось отправить сообщение о КРИТИЧЕСКОЙ ошибке в Telegram."); - } - // Не добавляем здесь Delay, чтобы не задерживать следующий цикл после критической ошибки. - // Worker перейдет к расчету следующего запуска. + try { await telegramSender.SendErrorMessageAsync($"Критическая ошибка в Worker: {ex.Message}", CancellationToken.None); } + catch (Exception sendEx) { _logger.LogError(sendEx, "Не удалось отправить сообщение о КРИТИЧЕСКОЙ ошибке."); } + // Не делаем Delay, чтобы быстрее перейти к следующему плановому запуску } } // Конец scope _logger.LogInformation("Процесс создания дайджеста завершен. Расчет следующего запуска."); - // Небольшая пауза (~1 сек) после выполнения, чтобы гарантированно перейти на следующую секунду - // и избежать повторного срабатывания в ту же минуту, если расчет времени даст очень маленькую задержку. - try - { - await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); - } - catch (OperationCanceledException) - { - _logger.LogInformation("Запрос на остановку получен во время короткой паузы после выполнения."); - break; - } + // Короткая пауза после выполнения, чтобы не запустить случайно дважды в одну секунду + try { await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); } + catch (OperationCanceledException) { break; } } // Конец while _logger.LogInformation("Worker останавливается."); } - // Метод для расчета задержки до ближайшего времени запуска из списка - private TimeSpan CalculateDelayUntilNearestRun(List runTimes) + // Метод для расчета задержки до ближайшего времени запуска в целевом часовом поясе + private TimeSpan CalculateDelayUntilNearestRunInTargetZone(List runTimesLocal, TimeZoneInfo targetZone) { - var now = DateTime.Now; - var today = now.Date; - TimeSpan currentTimeOfDay = now.TimeOfDay; + // Получаем ТЕКУЩЕЕ время в ЦЕЛЕВОМ часовом поясе + DateTime targetZoneNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, targetZone); + DateTime targetZoneToday = targetZoneNow.Date; + TimeSpan targetZoneCurrentTimeOfDay = targetZoneNow.TimeOfDay; - // Ищем ближайшее время запуска СЕГОДНЯ, которое еще не прошло (или равно текущему времени с точностью до секунды) - // Добавляем небольшую дельту (например, 1 секунду) к currentTimeOfDay, чтобы не пропустить запуск, - // который должен был произойти точно в текущую секунду. - TimeSpan? nextRunTimeToday = runTimes - .Where(t => t >= currentTimeOfDay) + _logger.LogDebug("Текущее время в целевой зоне ({TargetZoneId}): {TargetNow:yyyy-MM-dd HH:mm:ss}", targetZone.Id, targetZoneNow); + + // Ищем ближайшее время запуска СЕГОДНЯ (в целевой зоне), которое >= текущего времени + TimeSpan? nextRunTimeToday = runTimesLocal + .Where(t => t >= targetZoneCurrentTimeOfDay) .Cast() - .FirstOrDefault(); // Список уже отсортирован, берем первое подходящее + .FirstOrDefault(); // Используем FirstOrDefault, т.к. список отсортирован - DateTime nextRunDateTime; + DateTime nextRunTargetZoneDateTime; // Время следующего запуска в целевой зоне if (nextRunTimeToday.HasValue) { - // Если нашли время сегодня, планируем на сегодня - nextRunDateTime = today + nextRunTimeToday.Value; - _logger.LogDebug("Ближайшее время запуска сегодня: {RunTime:yyyy-MM-dd HH:mm:ss}", nextRunDateTime); + // Планируем на сегодня (в целевой зоне) + nextRunTargetZoneDateTime = targetZoneToday + nextRunTimeToday.Value; + _logger.LogDebug("Ближайшее время запуска сегодня (в целевой зоне): {RunTime:yyyy-MM-dd HH:mm:ss}", nextRunTargetZoneDateTime); } else { - // Если сегодня подходящего времени нет, берем самое раннее время из списка и планируем на ЗАВТРА - // runTimes гарантированно не пустой и отсортирован (проверено в конструкторе) - var earliestTimeTomorrow = runTimes.First(); - nextRunDateTime = today.AddDays(1) + earliestTimeTomorrow; - _logger.LogDebug("Все времена запуска сегодня прошли. Ближайшее время завтра: {RunTime:yyyy-MM-dd HH:mm:ss}", nextRunDateTime); + // Планируем на ЗАВТРА (в целевой зоне), используя самое раннее время + var earliestTime = runTimesLocal.First(); // Гарантированно есть и отсортировано + nextRunTargetZoneDateTime = targetZoneToday.AddDays(1) + earliestTime; + _logger.LogDebug("Все времена запуска сегодня (в целевой зоне) прошли. Ближайшее время завтра: {RunTime:yyyy-MM-dd HH:mm:ss}", nextRunTargetZoneDateTime); } - var delay = nextRunDateTime - now; + // Переводим рассчитанное время следующего запуска из целевой зоны обратно в UTC + DateTime nextRunUtc = TimeZoneInfo.ConvertTimeToUtc(nextRunTargetZoneDateTime, targetZone); - // Если задержка отрицательная или очень маленькая (меньше ~0), возвращаем Zero. + // Рассчитываем задержку от ТЕКУЩЕГО времени 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 вызван."); - // Можно добавить код для корректного завершения (например, Logout для WTelegramClient) return base.StopAsync(cancellationToken); } } diff --git a/appsettings.json b/appsettings.json index d8513f2..d314725 100644 --- a/appsettings.json +++ b/appsettings.json @@ -27,13 +27,13 @@ // Для Gemini API URL может быть не нужен, если используется клиентская библиотека Google }, "Scheduling": { - // Используем массив строк для указания времени "RunAtTimesLocal": [ - "18:14:00", + "09:00:00", "14:00:00", "18:00:00", "22:00:00" - ] + ], + "TargetTimeZoneId": "Asia/Novokuznetsk" // Укажите ваш IANA TimeZone ID }, "GeminiSettings": { "ModelName": "gemini-1.5-flash-latest", // <--- ИЗМЕНИТЬ ЗДЕСЬ