фикс с разными тгк

This commit is contained in:
Professional 2025-04-12 10:20:46 +07:00
parent b861e99ce2
commit 2553b13367
6 changed files with 262 additions and 170 deletions

View File

@ -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; }
}
}

View File

@ -1,11 +1,13 @@
namespace DailyDigestWorker.Configuration using System.Collections.Generic; // Для List
namespace DailyDigestWorker.Configuration
{ {
public class SchedulingSettings public class SchedulingSettings
{ {
// Список времен запуска в формате "HH:mm:ss" (это ВАШЕ локальное время) // Новый список: содержит объекты с временем и каналом
public List<string>? RunAtTimesLocal { get; set; } public List<ScheduleEntry>? Schedules { get; set; }
// Идентификатор целевого часового пояса (IANA ID) // Идентификатор целевого часового пояса остается
public string TargetTimeZoneId { get; set; } = "Asia/Novokuznetsk"; // Значение по умолчанию public string TargetTimeZoneId { get; set; } = "UTC"; // Безопасное значение по умолчанию
} }
} }

View File

@ -1,19 +1,12 @@
using System.Collections.Generic; namespace DailyDigestWorker.Services
using System.Threading;
using System.Threading.Tasks;
namespace DailyDigestWorker.Services
{ {
// Интерфейс для сервиса чтения сообщений из Telegram-канала
public interface ITelegramChannelReader public interface ITelegramChannelReader
{ {
/// <summary> // Добавляем параметр channelUsername
/// Получает тексты последних сообщений из целевого канала. Task<List<string>?> GetRecentNewsAsync(
/// </summary> string channelUsername, // <-- Имя канала для этого конкретного запуска
/// <param name="maxAgeHours">Максимальный возраст сообщений в часах.</param> int maxAgeHours,
/// <param name="limit">Максимальное количество сообщений для проверки.</param> int limit,
/// <param name="cancellationToken">Токен отмены.</param> CancellationToken cancellationToken);
/// <returns>Список текстов сообщений (от старых к новым) или null в случае ошибки.</returns>
Task<List<string>?> GetRecentNewsAsync(int maxAgeHours, int limit, CancellationToken cancellationToken);
} }
} }

View File

@ -1,94 +1,144 @@
using DailyDigestWorker.Configuration; // Для TelegramClientSettings using DailyDigestWorker.Configuration; // Не используется напрямую, но нужно для DI WTelegramClient
using Microsoft.Extensions.Options; // Для IOptions using Microsoft.Extensions.Options;
using TL; // Основное пространство имен WTelegramClient для типов Telegram API using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TL; // Основное пространство имен WTelegramClient
using WTelegram; // Для Client using WTelegram; // Для Client
namespace DailyDigestWorker.Services namespace DailyDigestWorker.Services
{ {
public class TelegramChannelReader : ITelegramChannelReader // Реализуем интерфейс public class TelegramChannelReader : ITelegramChannelReader // Реализуем обновленный интерфейс
{ {
private readonly ILogger<TelegramChannelReader> _logger; private readonly ILogger<TelegramChannelReader> _logger;
private readonly Client _client; // Наш Singleton WTelegramClient private readonly Client _client; // Наш Singleton WTelegramClient
private readonly TelegramClientSettings _settings; // Зависимость от IOptions<TelegramClientSettings> убрана
private InputPeer? _targetPeer; // Кэшированный InputPeer канала // Кэшированное поле _targetPeer убрано
// Внедряем зависимости // Конструктор теперь принимает только логгер и WTelegramClient
public TelegramChannelReader( public TelegramChannelReader(
ILogger<TelegramChannelReader> logger, ILogger<TelegramChannelReader> logger,
Client client, // Внедряем WTelegramClient Client client)
IOptions<TelegramClientSettings> settingsOptions)
{ {
_logger = logger; _logger = logger;
_client = client; _client = client;
_settings = settingsOptions.Value;
} }
public async Task<List<string>?> GetRecentNewsAsync(int maxAgeHours, int limit, CancellationToken cancellationToken) // Метод теперь принимает channelUsername как параметр
public async Task<List<string>?> GetRecentNewsAsync(
string channelUsername, // Имя канала для этого конкретного вызова
int maxAgeHours,
int limit,
CancellationToken cancellationToken)
{ {
_logger.LogInformation("Попытка чтения новостей из канала {ChannelUsername}...", _settings.TargetChannelUsername); // Внешний try-catch для диагностики любых ошибок
try try
{ {
_logger.LogDebug("Шаг 1: Проверка/выполнение авторизации пользователя..."); _logger.LogInformation("[GetRecentNewsAsync] Попытка чтения новостей из канала {ChannelUsername}...", channelUsername);
// Добавляем try-catch вокруг LoginUserIfNeeded для более детального логгирования
if (cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("[GetRecentNewsAsync] Операция отменена ПЕРЕД началом работы для канала {ChannelUsername}.", channelUsername);
return null;
}
_logger.LogDebug("[GetRecentNewsAsync] Шаг 1: Проверка/выполнение авторизации пользователя...");
User? currentUser = null; User? currentUser = null;
try try
{ {
currentUser = await _client.LoginUserIfNeeded(); // Может запросить ввод в консоль! // Выполняем логин, если необходимо (может запросить ввод в консоль)
currentUser = await _client.LoginUserIfNeeded().ConfigureAwait(false);
} }
catch (Exception loginEx) catch (Exception loginEx)
{ {
_logger.LogError(loginEx, "Ошибка во время выполнения LoginUserIfNeeded."); _logger.LogError(loginEx, "[GetRecentNewsAsync] Ошибка ВО ВРЕМЯ выполнения LoginUserIfNeeded.");
return null; // Выходим, если логин не удался return null;
} }
if (currentUser == null) if (currentUser == null)
{ {
_logger.LogError("Авторизация не удалась (LoginUserIfNeeded вернул null или выбросил исключение)."); _logger.LogError("[GetRecentNewsAsync] Авторизация не удалась (LoginUserIfNeeded вернул null или произошла ошибка выше).");
return 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) - кэшируем результат // 2. Найти канал (получить InputPeer) - делаем это каждый раз
if (_targetPeer == null) _logger.LogDebug("[GetRecentNewsAsync] Шаг 2: Поиск InputPeer для канала {ChannelUsername}...", channelUsername);
InputPeer? targetPeer = null; // Локальная переменная
try
{ {
_logger.LogDebug("Шаг 2: Поиск InputPeer для канала {ChannelUsername}...", _settings.TargetChannelUsername); var resolved = await _client.Contacts_ResolveUsername(channelUsername).ConfigureAwait(false);
var resolved = await _client.Contacts_ResolveUsername(_settings.TargetChannelUsername);
if (resolved?.UserOrChat is Channel channel) if (resolved?.UserOrChat is Channel channel)
{ {
_targetPeer = channel.ToInputPeer(); targetPeer = channel.ToInputPeer();
_logger.LogInformation("Шаг 2 Успех: Найден InputPeer для канала '{ChannelTitle}' (ID: {ChannelId})", channel.title, channel.id); _logger.LogInformation("[GetRecentNewsAsync] Шаг 2 Успех: Найден InputPeer для канала '{ChannelTitle}' (ID: {ChannelId})", channel.title, channel.id);
} }
else else
{ {
_logger.LogError("Шаг 2 Ошибка: Не удалось найти канал по юзернейму {ChannelUsername} или это не канал. Resolved: {@Resolved}", _settings.TargetChannelUsername, resolved); _logger.LogError("[GetRecentNewsAsync] Шаг 2 Ошибка: Не удалось найти канал по юзернейму {ChannelUsername} или это не канал. Resolved: {@Resolved}", channelUsername, resolved);
return null; return null;
} }
} }
else catch (RpcException e)
{ {
_logger.LogDebug("Шаг 2: Используется кэшированный InputPeer для канала."); _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;
} }
// 3. Получить историю сообщений
_logger.LogDebug("Шаг 3: Запрос истории сообщений (limit={Limit})...", limit); // Если targetPeer все еще null (не должно произойти при успешном resolve)
var history = await _client.Messages_GetHistory(_targetPeer, limit: limit); if (targetPeer == null)
if (history == null)
{ {
_logger.LogWarning("Шаг 3 Ошибка: Не удалось получить историю сообщений (результат null)."); _logger.LogError("[GetRecentNewsAsync] Критическая ошибка: InputPeer остался null после ResolveUsername для {ChannelUsername}", channelUsername);
return null; return null;
} }
_logger.LogInformation("Шаг 3 Успех: Получено {Count} сообщений/элементов в истории.", history.Messages.Length);
// 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. Отфильтровать сообщения по дате и извлечь текст // 4. Отфильтровать сообщения по дате и извлечь текст
_logger.LogDebug("Шаг 4: Фильтрация сообщений новее {CutoffDateUtc} UTC...", DateTime.UtcNow.AddHours(-maxAgeHours)); _logger.LogDebug("[GetRecentNewsAsync] Шаг 4: Фильтрация сообщений новее {CutoffDateUtc} UTC...", DateTime.UtcNow.AddHours(-maxAgeHours));
var newsTexts = new List<string>(); var newsTexts = new List<string>();
var cutoffDate = DateTime.UtcNow.AddHours(-maxAgeHours); var cutoffDate = DateTime.UtcNow.AddHours(-maxAgeHours);
foreach (var msgBase in history.Messages) foreach (var msgBase in history.Messages)
{ {
if (cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("[GetRecentNewsAsync] Операция отменена во время фильтрации сообщений.");
return null;
}
if (msgBase is Message message) if (msgBase is Message message)
{ {
if (message.Date >= cutoffDate) if (message.Date >= cutoffDate)
@ -96,35 +146,34 @@ namespace DailyDigestWorker.Services
if (!string.IsNullOrWhiteSpace(message.message)) if (!string.IsNullOrWhiteSpace(message.message))
{ {
newsTexts.Add(message.message); newsTexts.Add(message.message);
// Уменьшим детальность этого лога, чтобы не засорять вывод
// _logger.LogDebug(" + Добавлено сообщение от {MessageDate:yyyy-MM-dd HH:mm} UTC (ID: {MessageId})", message.Date, message.id);
} }
} }
else else
{ {
_logger.LogDebug("Достигнуто сообщение старше {CutoffDateUtc} UTC, остановка фильтрации.", cutoffDate); _logger.LogDebug("[GetRecentNewsAsync] Достигнуто сообщение старше {CutoffDateUtc} UTC, остановка фильтрации.", cutoffDate);
break; break; // Оптимизация: сообщения идут от новых к старым
} }
} }
} }
_logger.LogInformation("Шаг 4 Успех: Найдено {Count} новостных сообщений за последние {Hours} часов.", newsTexts.Count, maxAgeHours); _logger.LogInformation("[GetRecentNewsAsync] Шаг 4 Успех: Найдено {Count} новостных сообщений за последние {Hours} часов из канала {ChannelUsername}.", newsTexts.Count, maxAgeHours, channelUsername);
// Сообщения были от новых к старым, переворачиваем для хронологии
newsTexts.Reverse(); newsTexts.Reverse();
return newsTexts; return newsTexts;
} }
// ... (остальные catch блоки без изменений) ... catch (RpcException e) // Ловим ошибки Telegram API на уровне всего метода
catch (RpcException e)
{ {
_logger.LogError(e, "Ошибка Telegram API ({ErrorCode}): {ErrorMessage}", e.Code, e.Message); _logger.LogError(e, "[GetRecentNewsAsync] Ошибка Telegram API ({ErrorCode}) во время работы с каналом {ChannelUsername}: {ErrorMessage}", e.Code, channelUsername, e.Message);
if (e.Code == 420) if (e.Code == 420) // FLOOD_WAIT_X
{ {
_logger.LogWarning("Получен FloodWait на {Seconds} секунд. Пропускаем цикл чтения новостей.", e.X); _logger.LogWarning("[GetRecentNewsAsync] Получен FloodWait на {Seconds} секунд. Пропускаем чтение.", e.X);
} }
return null; return null;
} }
catch (Exception ex) catch (Exception ex) // Ловим ЛЮБЫЕ другие ошибки внутри метода
{ {
_logger.LogError(ex, "Неожиданная ошибка при чтении новостей из Telegram канала."); _logger.LogError(ex, "[GetRecentNewsAsync] Неожиданная ошибка ВНУТРИ метода для канала {ChannelUsername}.", channelUsername);
return null; return null;
} }
} }

198
Worker.cs
View File

@ -6,11 +6,14 @@ using Telegram.Bot.Types.Enums; // Для ParseMode
namespace DailyDigestWorker namespace DailyDigestWorker
{ {
// Структура для хранения распарсенного расписания (внутренняя)
internal readonly record struct ParsedScheduleEntry(TimeSpan TimeLocal, string ChannelUsername, DateTime NextRunTargetZone);
public class Worker : BackgroundService public class Worker : BackgroundService
{ {
private readonly ILogger<Worker> _logger; private readonly ILogger<Worker> _logger;
// Список времен запуска в формате TimeSpan (представляют ЛОКАЛЬНОЕ время ЦЕЛЕВОГО часового пояса) // Храним список пар (Время в целевой зоне, Имя канала)
private readonly List<TimeSpan> _runTimesLocal; private readonly List<(TimeSpan TimeLocal, string ChannelUsername)> _schedules;
private readonly TimeZoneInfo _targetTimeZone; // Целевой часовой пояс пользователя private readonly TimeZoneInfo _targetTimeZone; // Целевой часовой пояс пользователя
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
@ -20,12 +23,11 @@ namespace DailyDigestWorker
{ {
_logger = logger; _logger = logger;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_runTimesLocal = new List<TimeSpan>(); // Инициализируем список _schedules = new List<(TimeSpan TimeLocal, string ChannelUsername)>(); // Инициализируем список
// Получаем настройки планировщика (или используем дефолтные, если секция отсутствует)
var settings = schedulingSettings.Value ?? new SchedulingSettings(); var settings = schedulingSettings.Value ?? new SchedulingSettings();
var timeStrings = settings.RunAtTimesLocal; // Список строк времени var scheduleEntries = settings.Schedules; // Получаем новый список ScheduleEntry
string targetTimeZoneId = settings.TargetTimeZoneId; // ID часового пояса string targetTimeZoneId = settings.TargetTimeZoneId;
// 1. Определяем целевой часовой пояс // 1. Определяем целевой часовой пояс
try try
@ -36,77 +38,86 @@ namespace DailyDigestWorker
catch (TimeZoneNotFoundException) catch (TimeZoneNotFoundException)
{ {
_logger.LogError("Часовой пояс с ID '{TimeZoneId}' не найден в системе! Будет использовано локальное время сервера (TimeZoneInfo.Local).", targetTimeZoneId); _logger.LogError("Часовой пояс с ID '{TimeZoneId}' не найден в системе! Будет использовано локальное время сервера (TimeZoneInfo.Local).", targetTimeZoneId);
_targetTimeZone = TimeZoneInfo.Local; // Откат к локальному времени сервера _targetTimeZone = TimeZoneInfo.Local;
} }
catch (InvalidTimeZoneException ex) catch (InvalidTimeZoneException ex)
{ {
_logger.LogError(ex, "Ошибка при загрузке данных часового пояса '{TimeZoneId}'. Будет использовано локальное время сервера.", targetTimeZoneId); _logger.LogError(ex, "Ошибка при загрузке данных часового пояса '{TimeZoneId}'. Будет использовано локальное время сервера.", targetTimeZoneId);
_targetTimeZone = TimeZoneInfo.Local; _targetTimeZone = TimeZoneInfo.Local;
} }
catch (Exception ex) // Ловим другие возможные ошибки при работе с TimeZoneInfo catch (Exception ex)
{ {
_logger.LogError(ex, "Неожиданная ошибка при определении часового пояса '{TimeZoneId}'. Будет использовано локальное время сервера.", targetTimeZoneId); _logger.LogError(ex, "Неожиданная ошибка при определении часового пояса '{TimeZoneId}'. Будет использовано локальное время сервера.", targetTimeZoneId);
_targetTimeZone = TimeZoneInfo.Local; _targetTimeZone = TimeZoneInfo.Local;
} }
// 2. Парсим новое расписание из конфигурации
// 2. Парсим ВРЕМЕНА ЗАПУСКА (они всегда в целевом часовом поясе) if (scheduleEntries != null)
if (timeStrings != null)
{ {
foreach (var timeStr in timeStrings) foreach (var entry in scheduleEntries)
{ {
// Используем ParseExact для строгого формата "HH:mm:ss" if (entry != null &&
if (TimeSpan.TryParseExact(timeStr?.Trim(), @"hh\:mm\:ss", CultureInfo.InvariantCulture, out var timeSpan)) !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 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. Проверка и логирование времен запуска // 3. Проверка и логирование расписания
if (!_runTimesLocal.Any()) if (!_schedules.Any())
{ {
_logger.LogError("В конфигурации не указано ни одного корректного времени запуска (RunAtTimesLocal)! Worker не сможет автоматически запускаться."); _logger.LogError("В конфигурации не указано ни одного корректного расписания (Scheduling:Schedules)! Worker не сможет автоматически запускаться.");
// Здесь НЕТ добавления времени по умолчанию, чтобы требовать явную конфигурацию
} }
_logger.LogInformation("Worker инициализирован. Дайджест будет запускаться ежедневно в (по времени {TargetTimeZoneId}): {RunTimes}", _logger.LogInformation("Worker инициализирован. Расписание запусков (по времени {TargetTimeZoneId}):", _targetTimeZone.Id);
_targetTimeZone.Id, if (_schedules.Any())
_runTimesLocal.Any() ? string.Join(", ", _runTimesLocal.Select(t => t.ToString(@"hh\:mm\:ss"))) : "НЕТ ВРЕМЕН ЗАПУСКА"); {
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) 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 завершает работу."); _logger.LogError("Нет сконфигурированных расписаний запуска. Worker завершает работу.");
return; // Останавливаем выполнение, если нет времен return; // Останавливаем выполнение, если нет расписаний
} }
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
// Рассчитываем задержку до ближайшего времени запуска в целевом часовом поясе // Рассчитываем задержку до ближайшего запуска И получаем задание для этого запуска
TimeSpan delay = CalculateDelayUntilNearestRunInTargetZone(_runTimesLocal, _targetTimeZone); (TimeSpan delay, string channelForNextRun, DateTime nextRunTargetZone) = CalculateDelayAndNextSchedule(_schedules, _targetTimeZone);
// Логируем время следующего запуска в UTC и в целевой зоне _logger.LogInformation("Следующий запуск дайджеста из канала '{Channel}' запланирован через: {Delay} (в {TargetTimeLocal} {TargetZoneId})",
var nextRunUtc = DateTime.UtcNow.Add(delay); channelForNextRun, delay, nextRunTargetZone.ToString("yyyy-MM-dd HH:mm:ss"), _targetTimeZone.Id);
var nextRunTargetZone = TimeZoneInfo.ConvertTimeFromUtc(nextRunUtc, _targetTimeZone);
_logger.LogInformation("Следующий запуск дайджеста запланирован через: {Delay} (в {TargetTimeLocal} {TargetZoneId})",
delay, nextRunTargetZone.ToString("yyyy-MM-dd HH:mm:ss"), _targetTimeZone.Id);
try try
{ {
// Ожидаем до следующего времени запуска // Ожидаем рассчитанную задержку
await Task.Delay(delay, stoppingToken); await Task.Delay(delay, stoppingToken);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@ -117,11 +128,16 @@ namespace DailyDigestWorker
if (stoppingToken.IsCancellationRequested) break; // Проверяем еще раз 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()) using (var scope = _serviceProvider.CreateScope())
{ {
// --- Получаем сервисы --- // Получаем все необходимые сервисы
var currencyService = scope.ServiceProvider.GetRequiredService<ICurrencyService>(); var currencyService = scope.ServiceProvider.GetRequiredService<ICurrencyService>();
var cryptoService = scope.ServiceProvider.GetRequiredService<ICryptoService>(); var cryptoService = scope.ServiceProvider.GetRequiredService<ICryptoService>();
var weatherService = scope.ServiceProvider.GetRequiredService<IWeatherService>(); var weatherService = scope.ServiceProvider.GetRequiredService<IWeatherService>();
@ -130,18 +146,19 @@ namespace DailyDigestWorker
var newsReaderService = scope.ServiceProvider.GetRequiredService<ITelegramChannelReader>(); var newsReaderService = scope.ServiceProvider.GetRequiredService<ITelegramChannelReader>();
var summarizationService = scope.ServiceProvider.GetRequiredService<ISummarizationService>(); var summarizationService = scope.ServiceProvider.GetRequiredService<ISummarizationService>();
const int NewsMaxAgeHours = 24; const int NewsMaxAgeHours = 24; // Можно сделать динамическим
const int NewsFetchLimit = 50; const int NewsFetchLimit = 50;
string? newsSummary = null; string? newsSummary = null;
try try
{ {
// --- 1. Сбор данных --- // --- 1. Сбор данных ---
_logger.LogInformation("Начало сбора данных для дайджеста..."); _logger.LogInformation("1. Начало сбора данных...");
var currencyTask = currencyService.GetCurrencyRatesAsync(stoppingToken); var currencyTask = currencyService.GetCurrencyRatesAsync(stoppingToken);
var cryptoTask = cryptoService.GetCryptoRatesAsync(stoppingToken); var cryptoTask = cryptoService.GetCryptoRatesAsync(stoppingToken);
var weatherTask = weatherService.GetWeatherAsync(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); await Task.WhenAll(currencyTask, cryptoTask, weatherTask, newsTask);
@ -149,65 +166,59 @@ namespace DailyDigestWorker
var cryptoData = await cryptoTask; var cryptoData = await cryptoTask;
var weatherData = await weatherTask; var weatherData = await weatherTask;
var rawNewsTexts = await newsTask; var rawNewsTexts = await newsTask;
_logger.LogInformation("Сбор всех базовых данных завершен."); _logger.LogInformation("1. Сбор данных завершен.");
// --- 1.5 Генерация саммари --- // --- 1.5 Суммаризация ---
if (rawNewsTexts != null && rawNewsTexts.Any()) if (rawNewsTexts != null && rawNewsTexts.Any())
{ {
_logger.LogInformation("Получено {NewsCount} текстов новостей. Запуск суммаризации...", rawNewsTexts.Count); _logger.LogInformation("1.5 Получено {NewsCount} текстов из '{Channel}'. Запуск суммаризации...", rawNewsTexts.Count, currentChannelUsername);
try try
{ {
string combinedNewsText = string.Join("\n\n---\n\n", rawNewsTexts); string combinedNewsText = string.Join("\n\n---\n\n", rawNewsTexts);
// В будущем можно передать currentChannelUsername в SummarizeTextAsync для контекста промпта
newsSummary = await summarizationService.SummarizeTextAsync(combinedNewsText, stoppingToken); newsSummary = await summarizationService.SummarizeTextAsync(combinedNewsText, stoppingToken);
if (newsSummary != null)
{ if (newsSummary != null) { _logger.LogInformation("1.5 Суммаризация успешна."); }
_logger.LogInformation("Саммари новостей успешно получено."); else { _logger.LogWarning("1.5 Сервис суммаризации вернул null."); }
_logger.LogDebug("Текст саммари:\n{NewsSummary}", newsSummary); }
catch (OperationCanceledException) { _logger.LogInformation("1.5 Операция суммаризации отменена."); }
catch (Exception ex) { _logger.LogError(ex, "1.5 Ошибка во время вызова сервиса суммаризации."); }
} }
else else
{ {
_logger.LogWarning("Сервис суммаризации вернул null."); _logger.LogInformation("1.5 Нет текстов новостей для суммаризации из канала '{Channel}'.", currentChannelUsername);
}
}
catch (OperationCanceledException) { _logger.LogInformation("Операция суммаризации была отменена."); }
catch (Exception ex) { _logger.LogError(ex, "Ошибка во время вызова сервиса суммаризации."); }
}
else
{
_logger.LogInformation("Нет текстов новостей для суммаризации.");
} }
// --- 2. Формирование текста --- // --- 2. Формирование текста ---
_logger.LogInformation("Формирование текста дайджеста..."); _logger.LogInformation("2. Формирование текста дайджеста...");
// Можно передать currentChannelUsername для добавления в заголовок дайджеста
string digestText = digestBuilder.BuildDigestText(currencyData, cryptoData, weatherData, newsSummary); string digestText = digestBuilder.BuildDigestText(currencyData, cryptoData, weatherData, newsSummary);
_logger.LogDebug("Сформированный текст получен от DigestBuilder."); _logger.LogDebug("2. Сформированный текст получен от DigestBuilder.");
// --- 3. Отправка --- // --- 3. Отправка ---
if (!string.IsNullOrWhiteSpace(digestText)) if (!string.IsNullOrWhiteSpace(digestText))
{ {
_logger.LogInformation("Отправка дайджеста..."); _logger.LogInformation("3. Отправка дайджеста...");
bool sent = await telegramSender.SendMessageAsync(digestText, ParseMode.Markdown, stoppingToken); 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) catch (OperationCanceledException)
{ {
_logger.LogInformation("Запрос на остановку получен во время выполнения основной задачи."); _logger.LogInformation("Запрос на остановку получен во время выполнения основной задачи.");
break; break; // Выходим из while
} }
catch (Exception ex) catch (Exception ex)
{ // Ловим КРИТИЧЕСКИЕ ошибки цикла задачи { // Ловим КРИТИЧЕСКИЕ ошибки цикла задачи
_logger.LogError(ex, "Произошла КРИТИЧЕСКАЯ ошибка во время выполнения задачи создания дайджеста."); _logger.LogError(ex, "Произошла КРИТИЧЕСКАЯ ошибка во время выполнения задачи создания дайджеста для канала {Channel}.", currentChannelUsername);
try { await telegramSender.SendErrorMessageAsync($"Критическая ошибка в Worker: {ex.Message}", CancellationToken.None); } try { await telegramSender.SendErrorMessageAsync($"Критическая ошибка в Worker для канала {currentChannelUsername}: {ex.Message}", CancellationToken.None); }
catch (Exception sendEx) { _logger.LogError(sendEx, "Не удалось отправить сообщение о КРИТИЧЕСКОЙ ошибке."); } catch (Exception sendEx) { _logger.LogError(sendEx, "Не удалось отправить сообщение о КРИТИЧЕСКОЙ ошибке."); }
// Не делаем Delay, чтобы быстрее перейти к следующему плановому запуску
} }
} // Конец scope } // Конец scope
_logger.LogInformation("--- Завершение цикла обработки дайджеста для канала: [{ChannelUsername}] ---", currentChannelUsername);
_logger.LogInformation("Процесс создания дайджеста завершен. Расчет следующего запуска."); // Короткая пауза после выполнения
// Короткая пауза после выполнения, чтобы не запустить случайно дважды в одну секунду
try { await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); } try { await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); }
catch (OperationCanceledException) { break; } catch (OperationCanceledException) { break; }
@ -216,48 +227,51 @@ namespace DailyDigestWorker
_logger.LogInformation("Worker останавливается."); _logger.LogInformation("Worker останавливается.");
} }
// Метод для расчета задержки до ближайшего времени запуска в целевом часовом поясе // Метод расчета задержки И получения СЛЕДУЮЩЕГО задания из расписания
private TimeSpan CalculateDelayUntilNearestRunInTargetZone(List<TimeSpan> 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 targetZoneNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, targetZone);
DateTime targetZoneToday = targetZoneNow.Date; DateTime targetZoneToday = targetZoneNow.Date;
TimeSpan targetZoneCurrentTimeOfDay = targetZoneNow.TimeOfDay; 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 var nextScheduleToday = schedules
.Where(t => t >= targetZoneCurrentTimeOfDay) .Where(s => s.TimeLocal >= targetZoneCurrentTimeOfDay)
.Cast<TimeSpan?>() .Cast<(TimeSpan TimeLocal, string ChannelUsername)?>()
.FirstOrDefault(); // Используем FirstOrDefault, т.к. список отсортирован .FirstOrDefault();
(TimeSpan TimeLocal, string ChannelUsername) targetSchedule;
DateTime nextRunTargetZoneDateTime; // Время следующего запуска в целевой зоне DateTime nextRunTargetZoneDateTime; // Время следующего запуска в целевой зоне
if (nextRunTimeToday.HasValue) if (nextScheduleToday.HasValue)
{ {
// Планируем на сегодня (в целевой зоне) // Нашли запуск на сегодня
nextRunTargetZoneDateTime = targetZoneToday + nextRunTimeToday.Value; targetSchedule = nextScheduleToday.Value;
_logger.LogDebug("Ближайшее время запуска сегодня (в целевой зоне): {RunTime:yyyy-MM-dd HH:mm:ss}", nextRunTargetZoneDateTime); nextRunTargetZoneDateTime = targetZoneToday + targetSchedule.TimeLocal;
_logger.LogDebug("Расчет след. запуска: Найден запуск на сегодня ({Channel}) в {RunTime:HH:mm:ss}", targetSchedule.ChannelUsername, nextRunTargetZoneDateTime);
} }
else else
{ {
// Планируем на ЗАВТРА (в целевой зоне), используя самое раннее время // Все запуски сегодня прошли, берем первый запуск из списка на ЗАВТРА
var earliestTime = runTimesLocal.First(); // Гарантированно есть и отсортировано targetSchedule = schedules.First(); // Гарантированно есть и отсортировано
nextRunTargetZoneDateTime = targetZoneToday.AddDays(1) + earliestTime; nextRunTargetZoneDateTime = targetZoneToday.AddDays(1) + targetSchedule.TimeLocal;
_logger.LogDebug("Все времена запуска сегодня (в целевой зоне) прошли. Ближайшее время завтра: {RunTime:yyyy-MM-dd HH:mm:ss}", nextRunTargetZoneDateTime); _logger.LogDebug("Расчет след. запуска: Запуски на сегодня прошли. Следующий завтра ({Channel}) в {RunTime:HH:mm:ss}", targetSchedule.ChannelUsername, nextRunTargetZoneDateTime);
} }
// Переводим рассчитанное время следующего запуска из целевой зоны обратно в UTC // Переводим время следующего запуска из целевой зоны в UTC
DateTime nextRunUtc = TimeZoneInfo.ConvertTimeToUtc(nextRunTargetZoneDateTime, targetZone); DateTime nextRunUtc = TimeZoneInfo.ConvertTimeToUtc(nextRunTargetZoneDateTime, targetZone);
// Рассчитываем задержку от ТЕКУЩЕГО времени UTC // Рассчитываем задержку от ТЕКУЩЕГО времени UTC
TimeSpan delay = nextRunUtc - DateTime.UtcNow; 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) public override Task StopAsync(CancellationToken cancellationToken)

View File

@ -27,15 +27,34 @@
// Для Gemini API URL может быть не нужен, если используется клиентская библиотека Google // Для Gemini API URL может быть не нужен, если используется клиентская библиотека Google
}, },
"Scheduling": { "Scheduling": {
"RunAtTimesLocal": [ "TargetTimeZoneId": "Asia/Novokuznetsk", // Ваш часовой пояс
"09:00:00", "Schedules": [
"12:00:00", {
"15:00:00", "TimeLocal": "09:00:00",
"18:00:00", "ChannelUsername": "topor"
"21:00:00", }, // Пример
"00:00:00" {
], "TimeLocal": "12:00:00",
"TargetTimeZoneId": "Asia/Novokuznetsk" // Укажите ваш IANA TimeZone ID "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": { "GeminiSettings": {
"ModelName": "gemini-1.5-flash-latest", // <--- ИЗМЕНИТЬ ЗДЕСЬ "ModelName": "gemini-1.5-flash-latest", // <--- ИЗМЕНИТЬ ЗДЕСЬ