diff --git a/Configuration/ScheduleEntry.cs b/Configuration/ScheduleEntry.cs new file mode 100644 index 0000000..54c5a3c --- /dev/null +++ b/Configuration/ScheduleEntry.cs @@ -0,0 +1,15 @@ +namespace DailyDigestWorker.Configuration +{ + // Описывает одну запись в расписании + public class ScheduleEntry + { + // Время запуска (ваше локальное) в формате "HH:mm:ss" + public required string TimeLocal { get; set; } + + // Юзернейм канала Telegram (без @) для этого времени + public required string ChannelUsername { get; set; } + + // Опционально: можно добавить другие параметры, специфичные для этого запуска + // public string? CustomPromptSuffix { get; set; } + } +} \ No newline at end of file diff --git a/Configuration/SchedulingSettings.cs b/Configuration/SchedulingSettings.cs index 39cafca..94bb935 100644 --- a/Configuration/SchedulingSettings.cs +++ b/Configuration/SchedulingSettings.cs @@ -1,11 +1,13 @@ -namespace DailyDigestWorker.Configuration +using System.Collections.Generic; // Для List + +namespace DailyDigestWorker.Configuration { public class SchedulingSettings { - // Список времен запуска в формате "HH:mm:ss" (это ВАШЕ локальное время) - public List? RunAtTimesLocal { get; set; } + // Новый список: содержит объекты с временем и каналом + public List? Schedules { get; set; } - // Идентификатор целевого часового пояса (IANA ID) - public string TargetTimeZoneId { get; set; } = "Asia/Novokuznetsk"; // Значение по умолчанию + // Идентификатор целевого часового пояса остается + public string TargetTimeZoneId { get; set; } = "UTC"; // Безопасное значение по умолчанию } } \ No newline at end of file diff --git a/Services/ITelegramChannelReader.cs b/Services/ITelegramChannelReader.cs index 7576488..46550e9 100644 --- a/Services/ITelegramChannelReader.cs +++ b/Services/ITelegramChannelReader.cs @@ -1,19 +1,12 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace DailyDigestWorker.Services +namespace DailyDigestWorker.Services { - // Интерфейс для сервиса чтения сообщений из Telegram-канала public interface ITelegramChannelReader { - /// - /// Получает тексты последних сообщений из целевого канала. - /// - /// Максимальный возраст сообщений в часах. - /// Максимальное количество сообщений для проверки. - /// Токен отмены. - /// Список текстов сообщений (от старых к новым) или null в случае ошибки. - Task?> GetRecentNewsAsync(int maxAgeHours, int limit, CancellationToken cancellationToken); + // Добавляем параметр channelUsername + Task?> GetRecentNewsAsync( + string channelUsername, // <-- Имя канала для этого конкретного запуска + int maxAgeHours, + int limit, + CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/Services/TelegramChannelReader.cs b/Services/TelegramChannelReader.cs index 37aa8f2..aeab55a 100644 --- a/Services/TelegramChannelReader.cs +++ b/Services/TelegramChannelReader.cs @@ -1,94 +1,144 @@ -using DailyDigestWorker.Configuration; // Для TelegramClientSettings -using Microsoft.Extensions.Options; // Для IOptions -using TL; // Основное пространство имен WTelegramClient для типов Telegram API +using DailyDigestWorker.Configuration; // Не используется напрямую, но нужно для DI WTelegramClient +using Microsoft.Extensions.Options; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TL; // Основное пространство имен WTelegramClient using WTelegram; // Для Client namespace DailyDigestWorker.Services { - public class TelegramChannelReader : ITelegramChannelReader // Реализуем интерфейс + public class TelegramChannelReader : ITelegramChannelReader // Реализуем обновленный интерфейс { private readonly ILogger _logger; private readonly Client _client; // Наш Singleton WTelegramClient - private readonly TelegramClientSettings _settings; - private InputPeer? _targetPeer; // Кэшированный InputPeer канала + // Зависимость от IOptions убрана + // Кэшированное поле _targetPeer убрано - // Внедряем зависимости + // Конструктор теперь принимает только логгер и WTelegramClient public TelegramChannelReader( ILogger logger, - Client client, // Внедряем WTelegramClient - IOptions settingsOptions) + Client client) { _logger = logger; _client = client; - _settings = settingsOptions.Value; } - public async Task?> GetRecentNewsAsync(int maxAgeHours, int limit, CancellationToken cancellationToken) + // Метод теперь принимает channelUsername как параметр + public async Task?> GetRecentNewsAsync( + string channelUsername, // Имя канала для этого конкретного вызова + int maxAgeHours, + int limit, + CancellationToken cancellationToken) { - _logger.LogInformation("Попытка чтения новостей из канала {ChannelUsername}...", _settings.TargetChannelUsername); - + // Внешний try-catch для диагностики любых ошибок try { - _logger.LogDebug("Шаг 1: Проверка/выполнение авторизации пользователя..."); - // Добавляем try-catch вокруг LoginUserIfNeeded для более детального логгирования + _logger.LogInformation("[GetRecentNewsAsync] Попытка чтения новостей из канала {ChannelUsername}...", channelUsername); + + if (cancellationToken.IsCancellationRequested) + { + _logger.LogWarning("[GetRecentNewsAsync] Операция отменена ПЕРЕД началом работы для канала {ChannelUsername}.", channelUsername); + return null; + } + + _logger.LogDebug("[GetRecentNewsAsync] Шаг 1: Проверка/выполнение авторизации пользователя..."); User? currentUser = null; try { - currentUser = await _client.LoginUserIfNeeded(); // Может запросить ввод в консоль! + // Выполняем логин, если необходимо (может запросить ввод в консоль) + currentUser = await _client.LoginUserIfNeeded().ConfigureAwait(false); } catch (Exception loginEx) { - _logger.LogError(loginEx, "Ошибка во время выполнения LoginUserIfNeeded."); - return null; // Выходим, если логин не удался + _logger.LogError(loginEx, "[GetRecentNewsAsync] Ошибка ВО ВРЕМЯ выполнения LoginUserIfNeeded."); + return null; } if (currentUser == null) { - _logger.LogError("Авторизация не удалась (LoginUserIfNeeded вернул null или выбросил исключение)."); + _logger.LogError("[GetRecentNewsAsync] Авторизация не удалась (LoginUserIfNeeded вернул null или произошла ошибка выше)."); return null; } - _logger.LogInformation("Шаг 1 Успех: Авторизация как {UserFirstName} (ID: {UserId})", currentUser.first_name, currentUser.id); + _logger.LogInformation("[GetRecentNewsAsync] Шаг 1 Успех: Авторизация как {UserFirstName} (ID: {UserId})", currentUser.first_name, currentUser.id); - // 2. Найти канал (получить InputPeer) - кэшируем результат - if (_targetPeer == null) + // 2. Найти канал (получить InputPeer) - делаем это каждый раз + _logger.LogDebug("[GetRecentNewsAsync] Шаг 2: Поиск InputPeer для канала {ChannelUsername}...", channelUsername); + InputPeer? targetPeer = null; // Локальная переменная + try { - _logger.LogDebug("Шаг 2: Поиск InputPeer для канала {ChannelUsername}...", _settings.TargetChannelUsername); - var resolved = await _client.Contacts_ResolveUsername(_settings.TargetChannelUsername); + var resolved = await _client.Contacts_ResolveUsername(channelUsername).ConfigureAwait(false); if (resolved?.UserOrChat is Channel channel) { - _targetPeer = channel.ToInputPeer(); - _logger.LogInformation("Шаг 2 Успех: Найден InputPeer для канала '{ChannelTitle}' (ID: {ChannelId})", channel.title, channel.id); + targetPeer = channel.ToInputPeer(); + _logger.LogInformation("[GetRecentNewsAsync] Шаг 2 Успех: Найден InputPeer для канала '{ChannelTitle}' (ID: {ChannelId})", channel.title, channel.id); } else { - _logger.LogError("Шаг 2 Ошибка: Не удалось найти канал по юзернейму {ChannelUsername} или это не канал. Resolved: {@Resolved}", _settings.TargetChannelUsername, resolved); + _logger.LogError("[GetRecentNewsAsync] Шаг 2 Ошибка: Не удалось найти канал по юзернейму {ChannelUsername} или это не канал. Resolved: {@Resolved}", channelUsername, resolved); return null; } } - else + catch (RpcException e) { - _logger.LogDebug("Шаг 2: Используется кэшированный InputPeer для канала."); - } - - // 3. Получить историю сообщений - _logger.LogDebug("Шаг 3: Запрос истории сообщений (limit={Limit})...", limit); - var history = await _client.Messages_GetHistory(_targetPeer, limit: limit); - if (history == null) - { - _logger.LogWarning("Шаг 3 Ошибка: Не удалось получить историю сообщений (результат null)."); + _logger.LogError(e, "[GetRecentNewsAsync] Ошибка Telegram API ({ErrorCode}) при поиске канала {ChannelUsername}: {ErrorMessage}", e.Code, channelUsername, e.Message); return null; } - _logger.LogInformation("Шаг 3 Успех: Получено {Count} сообщений/элементов в истории.", history.Messages.Length); + catch (Exception ex) + { + _logger.LogError(ex, "[GetRecentNewsAsync] Неожиданная ошибка при поиске канала {ChannelUsername}.", channelUsername); + return null; + } + + + // Если targetPeer все еще null (не должно произойти при успешном resolve) + if (targetPeer == null) + { + _logger.LogError("[GetRecentNewsAsync] Критическая ошибка: InputPeer остался null после ResolveUsername для {ChannelUsername}", channelUsername); + return null; + } + + + // 3. Получить историю сообщений + _logger.LogDebug("[GetRecentNewsAsync] Шаг 3: Запрос истории сообщений (limit={Limit})...", limit); + Messages_MessagesBase? history = null; // Используем nullable тип + try + { + history = await _client.Messages_GetHistory(targetPeer, limit: limit).ConfigureAwait(false); + } + catch (RpcException e) + { + _logger.LogError(e, "[GetRecentNewsAsync] Ошибка Telegram API ({ErrorCode}) при получении истории для {ChannelUsername}: {ErrorMessage}", e.Code, channelUsername, e.Message); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "[GetRecentNewsAsync] Неожиданная ошибка при получении истории для {ChannelUsername}.", channelUsername); + return null; + } + + if (history == null) + { + _logger.LogWarning("[GetRecentNewsAsync] Шаг 3 Ошибка: Не удалось получить историю сообщений (результат null) для канала {ChannelUsername}.", channelUsername); + return null; + } + _logger.LogInformation("[GetRecentNewsAsync] Шаг 3 Успех: Получено {Count} сообщений/элементов в истории для канала {ChannelUsername}.", history.Messages.Length, channelUsername); // 4. Отфильтровать сообщения по дате и извлечь текст - _logger.LogDebug("Шаг 4: Фильтрация сообщений новее {CutoffDateUtc} UTC...", DateTime.UtcNow.AddHours(-maxAgeHours)); + _logger.LogDebug("[GetRecentNewsAsync] Шаг 4: Фильтрация сообщений новее {CutoffDateUtc} UTC...", DateTime.UtcNow.AddHours(-maxAgeHours)); var newsTexts = new List(); var cutoffDate = DateTime.UtcNow.AddHours(-maxAgeHours); foreach (var msgBase in history.Messages) { + if (cancellationToken.IsCancellationRequested) + { + _logger.LogWarning("[GetRecentNewsAsync] Операция отменена во время фильтрации сообщений."); + return null; + } + if (msgBase is Message message) { if (message.Date >= cutoffDate) @@ -96,35 +146,34 @@ namespace DailyDigestWorker.Services if (!string.IsNullOrWhiteSpace(message.message)) { newsTexts.Add(message.message); - // Уменьшим детальность этого лога, чтобы не засорять вывод - // _logger.LogDebug(" + Добавлено сообщение от {MessageDate:yyyy-MM-dd HH:mm} UTC (ID: {MessageId})", message.Date, message.id); } } else { - _logger.LogDebug("Достигнуто сообщение старше {CutoffDateUtc} UTC, остановка фильтрации.", cutoffDate); - break; + _logger.LogDebug("[GetRecentNewsAsync] Достигнуто сообщение старше {CutoffDateUtc} UTC, остановка фильтрации.", cutoffDate); + break; // Оптимизация: сообщения идут от новых к старым } } } - _logger.LogInformation("Шаг 4 Успех: Найдено {Count} новостных сообщений за последние {Hours} часов.", newsTexts.Count, maxAgeHours); + _logger.LogInformation("[GetRecentNewsAsync] Шаг 4 Успех: Найдено {Count} новостных сообщений за последние {Hours} часов из канала {ChannelUsername}.", newsTexts.Count, maxAgeHours, channelUsername); + // Сообщения были от новых к старым, переворачиваем для хронологии newsTexts.Reverse(); return newsTexts; + } - // ... (остальные catch блоки без изменений) ... - catch (RpcException e) + catch (RpcException e) // Ловим ошибки Telegram API на уровне всего метода { - _logger.LogError(e, "Ошибка Telegram API ({ErrorCode}): {ErrorMessage}", e.Code, e.Message); - if (e.Code == 420) + _logger.LogError(e, "[GetRecentNewsAsync] Ошибка Telegram API ({ErrorCode}) во время работы с каналом {ChannelUsername}: {ErrorMessage}", e.Code, channelUsername, e.Message); + if (e.Code == 420) // FLOOD_WAIT_X { - _logger.LogWarning("Получен FloodWait на {Seconds} секунд. Пропускаем цикл чтения новостей.", e.X); + _logger.LogWarning("[GetRecentNewsAsync] Получен FloodWait на {Seconds} секунд. Пропускаем чтение.", e.X); } return null; } - catch (Exception ex) + catch (Exception ex) // Ловим ЛЮБЫЕ другие ошибки внутри метода { - _logger.LogError(ex, "Неожиданная ошибка при чтении новостей из Telegram канала."); + _logger.LogError(ex, "[GetRecentNewsAsync] Неожиданная ошибка ВНУТРИ метода для канала {ChannelUsername}.", channelUsername); return null; } } diff --git a/Worker.cs b/Worker.cs index 9ba6524..1e7aa01 100644 --- a/Worker.cs +++ b/Worker.cs @@ -6,11 +6,14 @@ 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; - // Список времен запуска в формате TimeSpan (представляют ЛОКАЛЬНОЕ время ЦЕЛЕВОГО часового пояса) - private readonly List _runTimesLocal; + // Храним список пар (Время в целевой зоне, Имя канала) + private readonly List<(TimeSpan TimeLocal, string ChannelUsername)> _schedules; private readonly TimeZoneInfo _targetTimeZone; // Целевой часовой пояс пользователя private readonly IServiceProvider _serviceProvider; @@ -20,12 +23,11 @@ namespace DailyDigestWorker { _logger = logger; _serviceProvider = serviceProvider; - _runTimesLocal = new List(); // Инициализируем список + _schedules = new List<(TimeSpan TimeLocal, string ChannelUsername)>(); // Инициализируем список - // Получаем настройки планировщика (или используем дефолтные, если секция отсутствует) var settings = schedulingSettings.Value ?? new SchedulingSettings(); - var timeStrings = settings.RunAtTimesLocal; // Список строк времени - string targetTimeZoneId = settings.TargetTimeZoneId; // ID часового пояса + var scheduleEntries = settings.Schedules; // Получаем новый список ScheduleEntry + string targetTimeZoneId = settings.TargetTimeZoneId; // 1. Определяем целевой часовой пояс try @@ -36,77 +38,86 @@ namespace DailyDigestWorker catch (TimeZoneNotFoundException) { _logger.LogError("Часовой пояс с ID '{TimeZoneId}' не найден в системе! Будет использовано локальное время сервера (TimeZoneInfo.Local).", targetTimeZoneId); - _targetTimeZone = TimeZoneInfo.Local; // Откат к локальному времени сервера + _targetTimeZone = TimeZoneInfo.Local; } catch (InvalidTimeZoneException ex) { _logger.LogError(ex, "Ошибка при загрузке данных часового пояса '{TimeZoneId}'. Будет использовано локальное время сервера.", targetTimeZoneId); _targetTimeZone = TimeZoneInfo.Local; } - catch (Exception ex) // Ловим другие возможные ошибки при работе с TimeZoneInfo + catch (Exception ex) { _logger.LogError(ex, "Неожиданная ошибка при определении часового пояса '{TimeZoneId}'. Будет использовано локальное время сервера.", targetTimeZoneId); _targetTimeZone = TimeZoneInfo.Local; } - - // 2. Парсим ВРЕМЕНА ЗАПУСКА (они всегда в целевом часовом поясе) - if (timeStrings != null) + // 2. Парсим новое расписание из конфигурации + if (scheduleEntries != null) { - foreach (var timeStr in timeStrings) + foreach (var entry in scheduleEntries) { - // Используем ParseExact для строгого формата "HH:mm:ss" - if (TimeSpan.TryParseExact(timeStr?.Trim(), @"hh\:mm\:ss", CultureInfo.InvariantCulture, out var timeSpan)) + if (entry != null && + !string.IsNullOrWhiteSpace(entry.ChannelUsername) && + TimeSpan.TryParseExact(entry.TimeLocal?.Trim(), @"hh\:mm\:ss", CultureInfo.InvariantCulture, out var timeSpan)) { - _runTimesLocal.Add(timeSpan); + // Добавляем валидную пару (время, канал) в список + _schedules.Add((timeSpan, entry.ChannelUsername.Trim())); // Trim на случай лишних пробелов } else { - _logger.LogWarning("Не удалось распознать время '{TimeString}' в настройках Scheduling:RunAtTimesLocal. Используйте формат HH:mm:ss.", timeStr); + _logger.LogWarning("Некорректная или неполная запись в расписании Scheduling:Schedules: {@ScheduleEntry}. Запись проигнорирована.", entry); } } - // Убираем дубликаты и сортируем - _runTimesLocal = _runTimesLocal.Distinct().OrderBy(t => t).ToList(); + // Убираем дубликаты и сортируем по времени + _schedules = _schedules + .GroupBy(s => s.TimeLocal) // Группируем по времени, чтобы избежать дублей времени + .Select(g => g.First()) // Берем первую запись для каждого времени + .OrderBy(s => s.TimeLocal) // Сортируем по времени + .ToList(); } - // 3. Проверка и логирование времен запуска - if (!_runTimesLocal.Any()) + // 3. Проверка и логирование расписания + if (!_schedules.Any()) { - _logger.LogError("В конфигурации не указано ни одного корректного времени запуска (RunAtTimesLocal)! Worker не сможет автоматически запускаться."); - // Здесь НЕТ добавления времени по умолчанию, чтобы требовать явную конфигурацию + _logger.LogError("В конфигурации не указано ни одного корректного расписания (Scheduling:Schedules)! Worker не сможет автоматически запускаться."); } - _logger.LogInformation("Worker инициализирован. Дайджест будет запускаться ежедневно в (по времени {TargetTimeZoneId}): {RunTimes}", - _targetTimeZone.Id, - _runTimesLocal.Any() ? string.Join(", ", _runTimesLocal.Select(t => t.ToString(@"hh\:mm\:ss"))) : "НЕТ ВРЕМЕН ЗАПУСКА"); + _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); // Логгируем UTC время запуска + _logger.LogInformation("Worker запущен в: {time} UTC", DateTimeOffset.UtcNow); - if (!_runTimesLocal.Any()) + if (!_schedules.Any()) { - _logger.LogError("Нет сконфигурированных времен запуска. Worker завершает работу."); - return; // Останавливаем выполнение, если нет времен + _logger.LogError("Нет сконфигурированных расписаний запуска. Worker завершает работу."); + return; // Останавливаем выполнение, если нет расписаний } while (!stoppingToken.IsCancellationRequested) { - // Рассчитываем задержку до ближайшего времени запуска в целевом часовом поясе - TimeSpan delay = CalculateDelayUntilNearestRunInTargetZone(_runTimesLocal, _targetTimeZone); + // Рассчитываем задержку до ближайшего запуска И получаем задание для этого запуска + (TimeSpan delay, string channelForNextRun, DateTime nextRunTargetZone) = CalculateDelayAndNextSchedule(_schedules, _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); + _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) @@ -117,11 +128,16 @@ namespace DailyDigestWorker if (stoppingToken.IsCancellationRequested) break; // Проверяем еще раз + // --- Определяем задание для ТЕКУЩЕГО запуска --- + // Пересчитываем, чтобы получить актуальное задание на момент пробуждения + (_, string currentChannelUsername, _) = CalculateDelayAndNextSchedule(_schedules, _targetTimeZone); + _logger.LogInformation("--- Начало цикла обработки дайджеста для канала: [{ChannelUsername}] ({Time} UTC) ---", currentChannelUsername, DateTimeOffset.UtcNow); + + // --- Выполнение основной задачи --- - _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(); @@ -130,18 +146,19 @@ namespace DailyDigestWorker var newsReaderService = scope.ServiceProvider.GetRequiredService(); var summarizationService = scope.ServiceProvider.GetRequiredService(); - const int NewsMaxAgeHours = 24; + const int NewsMaxAgeHours = 24; // Можно сделать динамическим const int NewsFetchLimit = 50; string? newsSummary = null; try { // --- 1. Сбор данных --- - _logger.LogInformation("Начало сбора данных для дайджеста..."); + _logger.LogInformation("1. Начало сбора данных..."); var currencyTask = currencyService.GetCurrencyRatesAsync(stoppingToken); var cryptoTask = cryptoService.GetCryptoRatesAsync(stoppingToken); var weatherTask = weatherService.GetWeatherAsync(stoppingToken); - var newsTask = newsReaderService.GetRecentNewsAsync(NewsMaxAgeHours, NewsFetchLimit, stoppingToken); + // Передаем имя канала, актуальное для ЭТОГО запуска + var newsTask = newsReaderService.GetRecentNewsAsync(currentChannelUsername, NewsMaxAgeHours, NewsFetchLimit, stoppingToken); await Task.WhenAll(currencyTask, cryptoTask, weatherTask, newsTask); @@ -149,65 +166,59 @@ namespace DailyDigestWorker var cryptoData = await cryptoTask; var weatherData = await weatherTask; var rawNewsTexts = await newsTask; - _logger.LogInformation("Сбор всех базовых данных завершен."); + _logger.LogInformation("1. Сбор данных завершен."); - // --- 1.5 Генерация саммари --- + // --- 1.5 Суммаризация --- if (rawNewsTexts != null && rawNewsTexts.Any()) { - _logger.LogInformation("Получено {NewsCount} текстов новостей. Запуск суммаризации...", rawNewsTexts.Count); + _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("Саммари новостей успешно получено."); - _logger.LogDebug("Текст саммари:\n{NewsSummary}", newsSummary); - } - else - { - _logger.LogWarning("Сервис суммаризации вернул null."); - } + + if (newsSummary != null) { _logger.LogInformation("1.5 Суммаризация успешна."); } + else { _logger.LogWarning("1.5 Сервис суммаризации вернул null."); } } - catch (OperationCanceledException) { _logger.LogInformation("Операция суммаризации была отменена."); } - catch (Exception ex) { _logger.LogError(ex, "Ошибка во время вызова сервиса суммаризации."); } + catch (OperationCanceledException) { _logger.LogInformation("1.5 Операция суммаризации отменена."); } + catch (Exception ex) { _logger.LogError(ex, "1.5 Ошибка во время вызова сервиса суммаризации."); } } else { - _logger.LogInformation("Нет текстов новостей для суммаризации."); + _logger.LogInformation("1.5 Нет текстов новостей для суммаризации из канала '{Channel}'.", currentChannelUsername); } // --- 2. Формирование текста --- - _logger.LogInformation("Формирование текста дайджеста..."); + _logger.LogInformation("2. Формирование текста дайджеста..."); + // Можно передать currentChannelUsername для добавления в заголовок дайджеста string digestText = digestBuilder.BuildDigestText(currencyData, cryptoData, weatherData, newsSummary); - _logger.LogDebug("Сформированный текст получен от DigestBuilder."); + _logger.LogDebug("2. Сформированный текст получен от DigestBuilder."); // --- 3. Отправка --- if (!string.IsNullOrWhiteSpace(digestText)) { - _logger.LogInformation("Отправка дайджеста..."); + _logger.LogInformation("3. Отправка дайджеста..."); bool sent = await telegramSender.SendMessageAsync(digestText, ParseMode.Markdown, stoppingToken); - if (!sent) { _logger.LogWarning("Не удалось инициировать отправку дайджеста."); } + if (!sent) { _logger.LogWarning("3. Не удалось инициировать отправку дайджеста."); } } - else { _logger.LogWarning("Текст дайджеста пуст, отправка не производится."); } + else { _logger.LogWarning("3. Текст дайджеста пуст, отправка не производится."); } } catch (OperationCanceledException) { _logger.LogInformation("Запрос на остановку получен во время выполнения основной задачи."); - break; + break; // Выходим из while } catch (Exception ex) { // Ловим КРИТИЧЕСКИЕ ошибки цикла задачи - _logger.LogError(ex, "Произошла КРИТИЧЕСКАЯ ошибка во время выполнения задачи создания дайджеста."); - try { await telegramSender.SendErrorMessageAsync($"Критическая ошибка в Worker: {ex.Message}", CancellationToken.None); } + _logger.LogError(ex, "Произошла КРИТИЧЕСКАЯ ошибка во время выполнения задачи создания дайджеста для канала {Channel}.", currentChannelUsername); + try { await telegramSender.SendErrorMessageAsync($"Критическая ошибка в Worker для канала {currentChannelUsername}: {ex.Message}", CancellationToken.None); } catch (Exception sendEx) { _logger.LogError(sendEx, "Не удалось отправить сообщение о КРИТИЧЕСКОЙ ошибке."); } - // Не делаем Delay, чтобы быстрее перейти к следующему плановому запуску } } // Конец scope + _logger.LogInformation("--- Завершение цикла обработки дайджеста для канала: [{ChannelUsername}] ---", currentChannelUsername); - _logger.LogInformation("Процесс создания дайджеста завершен. Расчет следующего запуска."); - - // Короткая пауза после выполнения, чтобы не запустить случайно дважды в одну секунду + // Короткая пауза после выполнения try { await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); } catch (OperationCanceledException) { break; } @@ -216,48 +227,51 @@ namespace DailyDigestWorker _logger.LogInformation("Worker останавливается."); } - // Метод для расчета задержки до ближайшего времени запуска в целевом часовом поясе - private TimeSpan CalculateDelayUntilNearestRunInTargetZone(List runTimesLocal, TimeZoneInfo targetZone) + // Метод расчета задержки И получения СЛЕДУЮЩЕГО задания из расписания + 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}", targetZone.Id, targetZoneNow); + _logger.LogDebug("Расчет след. запуска: Текущее время в зоне {TargetZoneId}: {TargetNow:yyyy-MM-dd HH:mm:ss.fff}", targetZone.Id, targetZoneNow); - // Ищем ближайшее время запуска СЕГОДНЯ (в целевой зоне), которое >= текущего времени - TimeSpan? nextRunTimeToday = runTimesLocal - .Where(t => t >= targetZoneCurrentTimeOfDay) - .Cast() - .FirstOrDefault(); // Используем FirstOrDefault, т.к. список отсортирован + // Ищем первое расписание на СЕГОДНЯ (в целевой зоне), время которого >= текущего времени + var nextScheduleToday = schedules + .Where(s => s.TimeLocal >= targetZoneCurrentTimeOfDay) + .Cast<(TimeSpan TimeLocal, string ChannelUsername)?>() + .FirstOrDefault(); + (TimeSpan TimeLocal, string ChannelUsername) targetSchedule; DateTime nextRunTargetZoneDateTime; // Время следующего запуска в целевой зоне - if (nextRunTimeToday.HasValue) + if (nextScheduleToday.HasValue) { - // Планируем на сегодня (в целевой зоне) - nextRunTargetZoneDateTime = targetZoneToday + nextRunTimeToday.Value; - _logger.LogDebug("Ближайшее время запуска сегодня (в целевой зоне): {RunTime:yyyy-MM-dd HH:mm:ss}", nextRunTargetZoneDateTime); + // Нашли запуск на сегодня + targetSchedule = nextScheduleToday.Value; + nextRunTargetZoneDateTime = targetZoneToday + targetSchedule.TimeLocal; + _logger.LogDebug("Расчет след. запуска: Найден запуск на сегодня ({Channel}) в {RunTime:HH:mm:ss}", targetSchedule.ChannelUsername, nextRunTargetZoneDateTime); } else { - // Планируем на ЗАВТРА (в целевой зоне), используя самое раннее время - var earliestTime = runTimesLocal.First(); // Гарантированно есть и отсортировано - nextRunTargetZoneDateTime = targetZoneToday.AddDays(1) + earliestTime; - _logger.LogDebug("Все времена запуска сегодня (в целевой зоне) прошли. Ближайшее время завтра: {RunTime:yyyy-MM-dd HH:mm:ss}", nextRunTargetZoneDateTime); + // Все запуски сегодня прошли, берем первый запуск из списка на ЗАВТРА + targetSchedule = schedules.First(); // Гарантированно есть и отсортировано + nextRunTargetZoneDateTime = targetZoneToday.AddDays(1) + targetSchedule.TimeLocal; + _logger.LogDebug("Расчет след. запуска: Запуски на сегодня прошли. Следующий завтра ({Channel}) в {RunTime:HH:mm:ss}", targetSchedule.ChannelUsername, nextRunTargetZoneDateTime); } - // Переводим рассчитанное время следующего запуска из целевой зоны обратно в UTC + // Переводим время следующего запуска из целевой зоны в 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); + _logger.LogDebug("Расчет след. запуска: Время UTC {NextRunUtc:yyyy-MM-dd HH:mm:ss}Z. Задержка: {Delay}", nextRunUtc, delay); - // Если задержка отрицательная (уже опоздали), возвращаем Zero для немедленного запуска. - return delay < TimeSpan.Zero ? TimeSpan.Zero : delay; + // Возвращаем задержку (не меньше нуля), имя канала для этого запуска и время запуска в целевой зоне + return (delay < TimeSpan.Zero ? TimeSpan.Zero : delay, targetSchedule.ChannelUsername, nextRunTargetZoneDateTime); } public override Task StopAsync(CancellationToken cancellationToken) diff --git a/appsettings.json b/appsettings.json index c70daf4..530ad34 100644 --- a/appsettings.json +++ b/appsettings.json @@ -27,15 +27,34 @@ // Для Gemini API URL может быть не нужен, если используется клиентская библиотека Google }, "Scheduling": { - "RunAtTimesLocal": [ - "09:00:00", - "12:00:00", - "15:00:00", - "18:00:00", - "21:00:00", - "00:00:00" - ], - "TargetTimeZoneId": "Asia/Novokuznetsk" // Укажите ваш IANA TimeZone ID + "TargetTimeZoneId": "Asia/Novokuznetsk", // Ваш часовой пояс + "Schedules": [ + { + "TimeLocal": "09:00:00", + "ChannelUsername": "topor" + }, // Пример + { + "TimeLocal": "12:00:00", + "ChannelUsername": "exploitex" + }, // Пример + { + "TimeLocal": "15:00:00", + "ChannelUsername": "lentachold" + }, // Пример + { + "TimeLocal": "18:00:00", + "ChannelUsername": "breakingmash" + }, // Пример + { + "TimeLocal": "21:00:00", + "ChannelUsername": "meduzalive" + }, // Пример + { + "TimeLocal": "23:00:00", + "ChannelUsername": "readovkanews" + } // Пример + // Добавьте/измените время и каналы по вашему усмотрению + ] }, "GeminiSettings": { "ModelName": "gemini-1.5-flash-latest", // <--- ИЗМЕНИТЬ ЗДЕСЬ