283 lines
19 KiB
C#
283 lines
19 KiB
C#
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);
|
||
}
|
||
}
|
||
} |