daily_digest/Worker.cs
Professional c1ccf8645e cgjvh
2025-04-12 10:46:39 +07:00

283 lines
19 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Worker> _logger;
// Храним список пар (Время в целевой зоне, Имя канала)
private readonly List<(TimeSpan TimeLocal, string ChannelUsername)> _schedules;
private readonly TimeZoneInfo _targetTimeZone; // Целевой часовой пояс пользователя
private readonly IServiceProvider _serviceProvider;
public Worker(ILogger<Worker> logger,
IOptions<SchedulingSettings> 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<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>();
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);
}
}
}