diff --git a/Configuration/ApiKeys.cs b/Configuration/ApiKeys.cs new file mode 100644 index 0000000..46cfa5c --- /dev/null +++ b/Configuration/ApiKeys.cs @@ -0,0 +1,8 @@ +namespace DailyDigestWorker.Configuration +{ + public class ApiKeys + { + public required string OpenWeatherMap { get; set; } + public required string Gemini { get; set; } + } +} \ No newline at end of file diff --git a/Configuration/BotConfiguration.cs b/Configuration/BotConfiguration.cs new file mode 100644 index 0000000..bfbc32d --- /dev/null +++ b/Configuration/BotConfiguration.cs @@ -0,0 +1,8 @@ +namespace DailyDigestWorker.Configuration +{ + public class BotConfiguration + { + public required string BotToken { get; set; } // Используем required для обязательных полей (или string?) + public required string TargetChatId { get; set; } + } +} \ No newline at end of file diff --git a/Configuration/DataSourceUrls.cs b/Configuration/DataSourceUrls.cs new file mode 100644 index 0000000..702860a --- /dev/null +++ b/Configuration/DataSourceUrls.cs @@ -0,0 +1,9 @@ +namespace DailyDigestWorker.Configuration +{ + public class DataSourceUrls + { + public required string Cbr { get; set; } + public required string CoinGecko { get; set; } + public required string OpenWeatherMap { get; set; } + } +} \ No newline at end of file diff --git a/Configuration/GeminiSettings.cs b/Configuration/GeminiSettings.cs new file mode 100644 index 0000000..06f21d3 --- /dev/null +++ b/Configuration/GeminiSettings.cs @@ -0,0 +1,13 @@ +namespace DailyDigestWorker.Configuration +{ + public class GeminiSettings + { + // Формат URL для :generateContent. {0} - model, {1} - key + public string ApiEndpointFormat { get; set; } = "https://generativelanguage.googleapis.com/v1beta/models/{0}:generateContent?key={1}"; + public string ModelName { get; set; } = "gemini-pro"; // Базовая модель + public float Temperature { get; set; } = 0.6f; // Немного креативности, но не слишком + public int MaxOutputTokens { get; set; } = 1024; // Лимит токенов для саммари + // Добавить список SafetySettings, если нужно настраивать их + // public List SafetySettings { get; set; } + } +} \ No newline at end of file diff --git a/Configuration/SchedulingSettings.cs b/Configuration/SchedulingSettings.cs new file mode 100644 index 0000000..44999d5 --- /dev/null +++ b/Configuration/SchedulingSettings.cs @@ -0,0 +1,8 @@ +namespace DailyDigestWorker.Configuration +{ + public class SchedulingSettings + { + // Убираем значение по умолчанию. Список будет null, если не задан в конфиге. + public List? RunAtTimesLocal { get; set; } + } +} \ No newline at end of file diff --git a/Configuration/TelegramClientSettings.cs b/Configuration/TelegramClientSettings.cs new file mode 100644 index 0000000..5023ca5 --- /dev/null +++ b/Configuration/TelegramClientSettings.cs @@ -0,0 +1,11 @@ +namespace DailyDigestWorker.Configuration +{ + public class TelegramClientSettings + { + public int ApiId { get; set; } + public required string ApiHash { get; set; } + public required string PhoneNumber { get; set; } + public string SessionPath { get; set; } = "telegram_session.dat"; // Значение по умолчанию + public required string TargetChannelUsername { get; set; } + } +} \ No newline at end of file diff --git a/DailyDigestWorker.csproj b/DailyDigestWorker.csproj new file mode 100644 index 0000000..85e844c --- /dev/null +++ b/DailyDigestWorker.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + dotnet-DailyDigestWorker-14a227e9-5097-46b5-8901-6f6dab4c4a9c + + + + + + + + + + + + diff --git a/DailyDigestWorker.sln b/DailyDigestWorker.sln new file mode 100644 index 0000000..75b8629 --- /dev/null +++ b/DailyDigestWorker.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.13.35913.81 d17.13 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DailyDigestWorker", "DailyDigestWorker.csproj", "{478D0F55-D29D-45FF-9F4D-E962B2CCF712}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {478D0F55-D29D-45FF-9F4D-E962B2CCF712}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {478D0F55-D29D-45FF-9F4D-E962B2CCF712}.Debug|Any CPU.Build.0 = Debug|Any CPU + {478D0F55-D29D-45FF-9F4D-E962B2CCF712}.Release|Any CPU.ActiveCfg = Release|Any CPU + {478D0F55-D29D-45FF-9F4D-E962B2CCF712}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {FCD3A571-E08C-4502-A673-F400C42793FB} + EndGlobalSection +EndGlobal diff --git a/Models/CryptoData.cs b/Models/CryptoData.cs new file mode 100644 index 0000000..6038a0b --- /dev/null +++ b/Models/CryptoData.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; // Для Dictionary + +namespace DailyDigestWorker.Models +{ + // Модель для хранения курсов криптовалют + public class CryptoData + { + // Используем словарь: Идентификатор крипты (bitcoin, ethereum) -> Внутренний словарь (Валюта -> Курс) + public Dictionary> CryptoRates { get; } = new Dictionary>(); + // Пример: {"bitcoin": {"usd": 60000, "rub": 5000000}, "ethereum": ... } + } +} \ No newline at end of file diff --git a/Models/CurrencyData.cs b/Models/CurrencyData.cs new file mode 100644 index 0000000..e9c24a0 --- /dev/null +++ b/Models/CurrencyData.cs @@ -0,0 +1,10 @@ +namespace DailyDigestWorker.Models +{ + // Простая модель для хранения курсов валют + public class CurrencyData + { + // Используем словарь: Код валюты (USD/EUR) -> Курс (decimal) + public Dictionary Rates { get; } = new Dictionary(); + public DateTime Date { get; set; } // Дата, на которую установлены курсы + } +} \ No newline at end of file diff --git a/Models/Gemini/GeminiRequestDto.cs b/Models/Gemini/GeminiRequestDto.cs new file mode 100644 index 0000000..a99cd34 --- /dev/null +++ b/Models/Gemini/GeminiRequestDto.cs @@ -0,0 +1,49 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace DailyDigestWorker.Models.Gemini +{ + public class GeminiRequestDto + { + [JsonProperty("contents")] + public List Contents { get; set; } = new List(); + + // Опциональные параметры для контроля генерации + [JsonProperty("generationConfig", NullValueHandling = NullValueHandling.Ignore)] + public GenerationConfigDto? GenerationConfig { get; set; } + + // Опциональные параметры безопасности + [JsonProperty("safetySettings", NullValueHandling = NullValueHandling.Ignore)] + public List? SafetySettings { get; set; } + } + + public class ContentDto + { + [JsonProperty("parts")] + public List Parts { get; set; } = new List(); + } + + public class PartDto + { + [JsonProperty("text")] + public string Text { get; set; } = string.Empty; + } + + public class GenerationConfigDto + { + [JsonProperty("temperature", NullValueHandling = NullValueHandling.Ignore)] + public float? Temperature { get; set; } // 0.0 - 1.0 + + [JsonProperty("maxOutputTokens", NullValueHandling = NullValueHandling.Ignore)] + public int? MaxOutputTokens { get; set; } + } + + public class SafetySettingDto + { + [JsonProperty("category")] + public string Category { get; set; } = string.Empty; // e.g., "HARM_CATEGORY_HARASSMENT" + + [JsonProperty("threshold")] + public string Threshold { get; set; } = string.Empty; // e.g., "BLOCK_MEDIUM_AND_ABOVE" + } +} \ No newline at end of file diff --git a/Models/Gemini/GeminiResponseDto.cs b/Models/Gemini/GeminiResponseDto.cs new file mode 100644 index 0000000..56490af --- /dev/null +++ b/Models/Gemini/GeminiResponseDto.cs @@ -0,0 +1,49 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace DailyDigestWorker.Models.Gemini +{ + public class GeminiResponseDto + { + [JsonProperty("candidates")] + public List? Candidates { get; set; } + + [JsonProperty("promptFeedback", NullValueHandling = NullValueHandling.Ignore)] + public PromptFeedbackDto? PromptFeedback { get; set; } + } + + public class CandidateDto + { + [JsonProperty("content")] + public ContentDto? Content { get; set; } + + [JsonProperty("finishReason", NullValueHandling = NullValueHandling.Ignore)] + public string? FinishReason { get; set; } // e.g., "STOP", "MAX_TOKENS", "SAFETY" + + [JsonProperty("safetyRatings", NullValueHandling = NullValueHandling.Ignore)] + public List? SafetyRatings { get; set; } + } + + // ContentDto и PartDto используются и в запросе, и в ответе + + public class PromptFeedbackDto + { + [JsonProperty("blockReason", NullValueHandling = NullValueHandling.Ignore)] + public string? BlockReason { get; set; } + + [JsonProperty("safetyRatings", NullValueHandling = NullValueHandling.Ignore)] + public List? SafetyRatings { get; set; } + } + + public class SafetyRatingDto + { + [JsonProperty("category")] + public string? Category { get; set; } + + [JsonProperty("probability")] + public string? Probability { get; set; } // e.g., "NEGLIGIBLE", "LOW", "MEDIUM", "HIGH" + + [JsonProperty("blocked", NullValueHandling = NullValueHandling.Ignore)] + public bool? Blocked { get; set; } + } +} \ No newline at end of file diff --git a/Models/OpenWeatherMapDtos.cs b/Models/OpenWeatherMapDtos.cs new file mode 100644 index 0000000..42b8c47 --- /dev/null +++ b/Models/OpenWeatherMapDtos.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; // Используем атрибуты JsonProperty + +namespace DailyDigestWorker.Models.OpenWeatherMap +{ + // Основной класс ответа API + public class WeatherResponseDto + { + [JsonProperty("weather")] + public List? Weather { get; set; } // Массив описаний погоды (обычно 1 элемент) + + [JsonProperty("main")] + public MainWeatherDataDto? Main { get; set; } // Основные погодные данные (температура и т.д.) + + [JsonProperty("cod")] + public object? Cod { get; set; } // Код ответа (200 - OK) - можно использовать для доп. проверки + } + + // Класс для описания погоды (description, icon) + public class WeatherDescriptionDto + { + [JsonProperty("description")] + public string? Description { get; set; } // Описание (напр., "ясно", "небольшой дождь") + + [JsonProperty("icon")] + public string? Icon { get; set; } // Код иконки погоды (для URL или Emoji) + } + + // Класс для основных данных (температура, ощущаемая температура) + public class MainWeatherDataDto + { + [JsonProperty("temp")] + public decimal Temp { get; set; } // Температура (в градусах Цельсия, т.к. мы указали units=metric) + + [JsonProperty("feels_like")] + public decimal FeelsLike { get; set; } // Ощущаемая температура + } +} \ No newline at end of file diff --git a/Models/WeatherData.cs b/Models/WeatherData.cs new file mode 100644 index 0000000..f202396 --- /dev/null +++ b/Models/WeatherData.cs @@ -0,0 +1,31 @@ +namespace DailyDigestWorker.Models +{ + public class WeatherData + { + public decimal TemperatureC { get; set; } + public decimal FeelsLikeC { get; set; } + public string Description { get; set; } = string.Empty; // Описание погоды + public string Icon { get; set; } = string.Empty; // Код иконки + + // Вспомогательное свойство для получения Emoji по коду иконки (опционально) + public string Emoji => GetEmojiForIcon(Icon); + + // Простая функция для маппинга кодов OWM на Emoji + private static string GetEmojiForIcon(string icon) => icon switch + { + "01d" => "☀️", // clear sky day + "01n" => "🌙", // clear sky night + "02d" => "⛅", // few clouds day + "02n" => "☁️", // few clouds night (используем ту же иконку) + "03d" or "03n" => "☁️", // scattered clouds + "04d" or "04n" => "☁️", // broken clouds (можно "🌥️" или "☁️") + "09d" or "09n" => "🌧️", // shower rain + "10d" => "🌦️", // rain day + "10n" => "🌧️", // rain night + "11d" or "11n" => "⛈️", // thunderstorm + "13d" or "13n" => "❄️", // snow + "50d" or "50n" => "🌫️", // mist + _ => "" // Неизвестная иконка + }; + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..31743f6 --- /dev/null +++ b/Program.cs @@ -0,0 +1,101 @@ +using DailyDigestWorker; +using DailyDigestWorker.Configuration; // using +using DailyDigestWorker.Services; +using Microsoft.Extensions.Options; // IOptions +using Telegram.Bot; // Telegram.Bot +using WTelegram; // WTelegramClient +using System.Text; + + +Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + +// --- Program.cs --- + +var builder = Host.CreateApplicationBuilder(args); // ApplicationBuilder + +// --- --- +// builder.Configuration ( appsettings.json, env vars, user secrets ..) + +// POCO +// IOptions +builder.Services.Configure(builder.Configuration.GetSection("BotConfiguration")); +builder.Services.Configure(builder.Configuration.GetSection("TelegramClient")); +builder.Services.Configure(builder.Configuration.GetSection("ApiKeys")); +builder.Services.Configure(builder.Configuration.GetSection("DataSourceUrls")); +builder.Services.Configure(builder.Configuration.GetSection("GeminiSettings")); +// SchedulingSettings +builder.Services.Configure(builder.Configuration.GetSection("Scheduling")); + + +// --- (Dependency Injection) --- + +// 1. HttpClientFactory ( API , , , Gemini) +builder.Services.AddHttpClient(); + +// 2. Telegram Bot Client ( ) +// Singleton, +builder.Services.AddSingleton(sp => // sp - Service Provider +{ + // IOptions + var botConfig = sp.GetRequiredService>().Value; + if (string.IsNullOrWhiteSpace(botConfig.BotToken)) + { + // , - + throw new InvalidOperationException("Bot Token appsettings.json ."); + } + return new TelegramBotClient(botConfig.BotToken); +}); + +// 3. WTelegramClient ( ) +// Singleton. WTelegramClient . +builder.Services.AddSingleton(sp => // Client +{ + var clientSettings = sp.GetRequiredService>().Value; + var logger = sp.GetRequiredService>(); // + + // WTelegramClient + string? GetConfig(string configKey) + { + switch (configKey) + { + case "api_id": return clientSettings.ApiId.ToString(); + case "api_hash": return clientSettings.ApiHash; + case "phone_number": return clientSettings.PhoneNumber; + case "session_pathname": return clientSettings.SessionPath; // <--- CASE + // "password", "verification_code" . + default: + logger.LogWarning("WTelegramClient : {ConfigKey}", configKey); + return null; // null + } + } + // WTelegramClient + var client = new WTelegram.Client(GetConfig); + + // WTelegramClient (, ) + // WTelegram Microsoft.Extensions.Logging, + // , : + // WTelegram.Helpers.Log = (level, message) => logger.Log((LogLevel)level, message); + + + // + + return client; +}); + +// 4. +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +// 5. +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient();// <-- + +// 6. Worker Service ( ) +builder.Services.AddHostedService(); + +// --- --- +var host = builder.Build(); +host.Run(); \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..63ab6f4 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "DailyDigestWorker": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Services/CbrCurrencyService.cs b/Services/CbrCurrencyService.cs new file mode 100644 index 0000000..f106b42 --- /dev/null +++ b/Services/CbrCurrencyService.cs @@ -0,0 +1,132 @@ +using DailyDigestWorker.Configuration; // Для DataSourceUrls +using DailyDigestWorker.Models; +using Microsoft.Extensions.Options; // Для IOptions +using System.Globalization; // Для CultureInfo и NumberStyles +using System.Xml.Linq; // Для работы с XML + +namespace DailyDigestWorker.Services +{ + public class CbrCurrencyService : ICurrencyService // Реализуем интерфейс + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly DataSourceUrls _dataSourceUrls; + + // Внедряем зависимости + public CbrCurrencyService( + IHttpClientFactory httpClientFactory, + IOptions dataSourceUrlsOptions, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + _dataSourceUrls = dataSourceUrlsOptions.Value; // Получаем URL из настроек + } + + public async Task GetCurrencyRatesAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Запрос курсов валют с API ЦБ РФ..."); + var client = _httpClientFactory.CreateClient(); // Создаем HttpClient + + try + { + // Выполняем GET-запрос к URL из конфигурации + HttpResponseMessage response = await client.GetAsync(_dataSourceUrls.Cbr, cancellationToken); + + // Проверяем, успешен ли запрос + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Ошибка при запросе к API ЦБ РФ. Статус код: {StatusCode}", response.StatusCode); + return null; // Возвращаем null при ошибке HTTP + } + + // Читаем ответ как поток (для работы с XML) + using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); + + // Загружаем XML из потока + XDocument xmlDoc = await XDocument.LoadAsync(responseStream, LoadOptions.None, cancellationToken); + + // Ищем нужные валюты (USD и EUR) + var usdElement = xmlDoc.Root?.Elements("Valute") + .FirstOrDefault(v => v.Attribute("ID")?.Value == "R01235"); // ID для USD + var eurElement = xmlDoc.Root?.Elements("Valute") + .FirstOrDefault(v => v.Attribute("ID")?.Value == "R01239"); // ID для EUR + + if (usdElement == null || eurElement == null) + { + _logger.LogError("Не удалось найти элементы USD или EUR в XML ответе ЦБ РФ."); + return null; + } + + // Получаем дату курсов из атрибута Date корневого элемента + DateTime coursesDate = DateTime.Today; // Значение по умолчанию + var dateAttribute = xmlDoc.Root?.Attribute("Date")?.Value; + if (!string.IsNullOrEmpty(dateAttribute) && DateTime.TryParseExact(dateAttribute, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedDate)) + { + coursesDate = parsedDate; + } + else + { + _logger.LogWarning("Не удалось распарсить дату курсов из XML ЦБ РФ. Используется текущая дата."); + } + + + // Извлекаем значения курсов (в виде строк) + var usdValueStr = usdElement.Element("Value")?.Value; + var eurValueStr = eurElement.Element("Value")?.Value; + + if (usdValueStr == null || eurValueStr == null) + { + _logger.LogError("Не удалось найти значения Value для USD или EUR в XML."); + return null; + } + + // Парсим строки в decimal, учитывая русскую культуру (запятая как разделитель) + var culture = CultureInfo.GetCultureInfo("ru-RU"); + if (decimal.TryParse(usdValueStr, NumberStyles.Any, culture, out decimal usdRate) && + decimal.TryParse(eurValueStr, NumberStyles.Any, culture, out decimal eurRate)) + { + _logger.LogInformation("Курсы валют успешно получены: USD={UsdRate}, EUR={EurRate} на {Date}", usdRate, eurRate, coursesDate.ToString("dd.MM.yyyy")); + + // Сначала создаем объект CurrencyData + var resultData = new CurrencyData + { + Date = coursesDate + }; + + // Затем добавляем элементы в его словарь Rates + resultData.Rates.Add("USD", usdRate); + resultData.Rates.Add("EUR", eurRate); + + // Возвращаем готовый объект + return resultData; + } + else + { + _logger.LogError("Не удалось преобразовать строковые значения курсов в decimal."); + return null; + } + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Ошибка HTTP при запросе к API ЦБ РФ."); + return null; + } + catch (System.Xml.XmlException ex) + { + _logger.LogError(ex, "Ошибка парсинга XML ответа ЦБ РФ."); + return null; + } + catch (OperationCanceledException) // Ловим отмену операции + { + _logger.LogInformation("Запрос курсов валют был отменен."); + return null; // Возвращаем null, если операция отменена + } + catch (Exception ex) // Ловим любые другие неожиданные ошибки + { + _logger.LogError(ex, "Неожиданная ошибка при получении курсов валют ЦБ РФ."); + return null; + } + } + } +} \ No newline at end of file diff --git a/Services/CoinGeckoCryptoService.cs b/Services/CoinGeckoCryptoService.cs new file mode 100644 index 0000000..c848413 --- /dev/null +++ b/Services/CoinGeckoCryptoService.cs @@ -0,0 +1,97 @@ +using DailyDigestWorker.Configuration; // Для DataSourceUrls +using DailyDigestWorker.Models; +using Microsoft.Extensions.Options; // Для IOptions +using Newtonsoft.Json; // Для JsonConvert (или System.Text.Json) +using Newtonsoft.Json.Linq; // Для JObject, если парсить вручную + +namespace DailyDigestWorker.Services +{ + public class CoinGeckoCryptoService : ICryptoService // Реализуем интерфейс + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly DataSourceUrls _dataSourceUrls; + + // Внедряем зависимости + public CoinGeckoCryptoService( + IHttpClientFactory httpClientFactory, + IOptions dataSourceUrlsOptions, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + _dataSourceUrls = dataSourceUrlsOptions.Value; // Получаем URL из настроек + } + + public async Task GetCryptoRatesAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Запрос курсов криптовалют с API CoinGecko..."); + var client = _httpClientFactory.CreateClient(); + + try + { + HttpResponseMessage response = await client.GetAsync(_dataSourceUrls.CoinGecko, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Ошибка при запросе к API CoinGecko. Статус код: {StatusCode}", response.StatusCode); + return null; + } + + // Читаем ответ как строку + string jsonResponse = await response.Content.ReadAsStringAsync(cancellationToken); + + // Десериализуем JSON + // CoinGecko API возвращает структуру типа: {"bitcoin": {"usd": 60000, "rub": 5000000}, "ethereum": {...}} + // Мы можем десериализовать это прямо в наш тип словаря. + var rates = JsonConvert.DeserializeObject>>(jsonResponse); + + if (rates == null || rates.Count == 0) + { + _logger.LogWarning("Не удалось десериализовать ответ от CoinGecko или он пуст."); + return null; + } + + _logger.LogInformation("Курсы криптовалют успешно получены из CoinGecko."); + + // Создаем и возвращаем результат + var resultData = new CryptoData(); + foreach (var cryptoPair in rates) + { + resultData.CryptoRates.Add(cryptoPair.Key, cryptoPair.Value); + } + + // Логгируем полученные значения для проверки (опционально) + foreach (var crypto in resultData.CryptoRates) + { + foreach (var currencyRate in crypto.Value) + { + _logger.LogDebug(" - {CryptoId} [{Currency}]: {Rate}", crypto.Key, currencyRate.Key.ToUpper(), currencyRate.Value); + } + } + + return resultData; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Ошибка HTTP при запросе к API CoinGecko."); + return null; + } + catch (JsonException ex) // Ловим ошибки десериализации JSON + { + _logger.LogError(ex, "Ошибка парсинга JSON ответа CoinGecko."); + return null; + } + catch (OperationCanceledException) + { + _logger.LogInformation("Запрос курсов криптовалют был отменен."); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Неожиданная ошибка при получении курсов криптовалют CoinGecko."); + return null; + } + } + } +} \ No newline at end of file diff --git a/Services/DigestBuilderService.cs b/Services/DigestBuilderService.cs new file mode 100644 index 0000000..a93033b --- /dev/null +++ b/Services/DigestBuilderService.cs @@ -0,0 +1,113 @@ +using DailyDigestWorker.Models; +using System.Globalization; +using System.Text; + +namespace DailyDigestWorker.Services +{ + public class DigestBuilderService : IDigestBuilderService + { + private readonly ILogger _logger; + + public DigestBuilderService(ILogger logger) + { + _logger = logger; + } + + public string BuildDigestText(CurrencyData? currency, CryptoData? crypto, WeatherData? weather, string? newsSummary) + { + _logger.LogInformation("Начало формирования улучшенного текста дайджеста..."); + var sb = new StringBuilder(); + var culture = CultureInfo.GetCultureInfo("ru-RU"); + var nl = Environment.NewLine; // Используем системный перенос строки для читаемости кода + + // --- Заголовок --- + sb.Append("🗓️ *").Append(DateTime.Now.ToString("dd MMMM yyyy", culture)).Append("*").AppendLine(" | Ежедневный Дайджест (Новокузнецк)"); + sb.AppendLine("--------------------------------------"); // Разделитель + + // --- Блок Погоды (перенесем наверх) --- + if (weather != null) + { + sb.Append(weather.Emoji).Append(" *Погода:* "); + sb.Append(weather.TemperatureC.ToString("F0", culture)).Append("°C "); + sb.Append($"_(ощущ. {weather.FeelsLikeC.ToString("F0", culture)}°C)_ "); + sb.Append("| ").Append(CultureInfo.CurrentCulture.TextInfo.ToTitleCase(weather.Description)); + sb.AppendLine(); // Перенос строки после погоды + } + else + { + sb.AppendLine("☁️ *Погода:* _(Не удалось получить данные)_"); + } + sb.AppendLine(); // Доп. отступ + + // --- Блок Валют --- + sb.AppendLine("💰 *Курсы Валют*"); + if (currency != null) + { + sb.AppendLine($"_(на {currency.Date:dd.MM})_"); + foreach (var rate in currency.Rates.OrderBy(r => r.Key)) // Сортируем для порядка (EUR, USD) + { + string symbol = rate.Key == "USD" ? "🇺🇸" : (rate.Key == "EUR" ? "🇪🇺" : "▪️"); + sb.AppendLine($" {symbol} {rate.Key}: `{rate.Value.ToString("N2", culture)} ₽`"); // Используем моноширинный шрифт для чисел + } + } + else + { + sb.AppendLine(" _(Не удалось получить данные)_"); + } + sb.AppendLine(); + + // --- Блок Криптовалют --- + sb.AppendLine("₿ *Криптовалюты*"); + if (crypto != null && crypto.CryptoRates.Any()) + { + foreach (var cryptoPair in crypto.CryptoRates.OrderBy(p => p.Key)) // Сортируем (Bitcoin, Ethereum) + { + string cryptoName = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(cryptoPair.Key); + var ratesText = new List(); + // Сортируем валюты (RUB, USD) + foreach (var ratePair in cryptoPair.Value.OrderBy(r => r.Key == "USD" ? 0 : 1)) + { + string format = ratePair.Key.ToLower() == "usd" ? "N0" : "N2"; + string symbol = ratePair.Key.ToLower() == "usd" ? "$" : "₽"; + ratesText.Add($"{symbol}{ratePair.Value.ToString(format, culture)}"); // Убрали (USD/RUB) для краткости + } + sb.AppendLine($" 📈 {cryptoName}: `{string.Join(" / ", ratesText)}`"); // Используем моноширинный + } + } + else + { + sb.AppendLine(" _(Не удалось получить данные)_"); + } + sb.AppendLine(); + + // --- Блок Новостей --- + if (!string.IsNullOrWhiteSpace(newsSummary)) + { + _logger.LogDebug("Добавление блока новостей в дайджест."); + sb.AppendLine("📰 *Сводка новостей за сутки*"); + // Добавляем саммари, предполагая, что Gemini вернул Markdown-список + // Убираем лишние пустые строки из ответа Gemini, если они есть + var summaryLines = newsSummary.Trim().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in summaryLines) + { + // Добавляем небольшой отступ к каждой строке новости для лучшего вида + sb.Append(" ").AppendLine(line.Trim()); + } + // sb.AppendLine("---"); // Убрал разделитель после новостей, блок последний + } + else + { + _logger.LogDebug("Саммари новостей пустое или не получено, блок новостей не добавляется."); + // sb.AppendLine("📰 *Сводка новостей за сутки*"); + // sb.AppendLine(" _(Нет новостей или не удалось обработать)_"); + } + // sb.AppendLine(); // Убрал лишний отступ в конце + + string result = sb.ToString().TrimEnd(); + _logger.LogInformation("Текст дайджеста успешно сформирован (улучшенный)."); + _logger.LogDebug("Итоговый текст дайджеста:\n{DigestText}", result); + + return result; + } + } +} \ No newline at end of file diff --git a/Services/GoogleGeminiSummarizationService.cs b/Services/GoogleGeminiSummarizationService.cs new file mode 100644 index 0000000..a2f3664 --- /dev/null +++ b/Services/GoogleGeminiSummarizationService.cs @@ -0,0 +1,164 @@ +using DailyDigestWorker.Configuration; +using DailyDigestWorker.Models.Gemini; // Наши DTO +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using System.Text; // Для StringBuilder и Encoding + +namespace DailyDigestWorker.Services +{ + public class GoogleGeminiSummarizationService : ISummarizationService + { + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ApiKeys _apiKeys; + private readonly GeminiSettings _geminiSettings; + private readonly string _apiKey; + + // Внедряем зависимости + public GoogleGeminiSummarizationService( + ILogger logger, + IHttpClientFactory httpClientFactory, + IOptions apiKeysOptions, + IOptions geminiSettingsOptions) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _apiKeys = apiKeysOptions.Value; + _geminiSettings = geminiSettingsOptions.Value; + _apiKey = _apiKeys.Gemini; // Сохраняем ключ для удобства + } + + public async Task SummarizeTextAsync(string textToSummarize, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_apiKey)) + { + _logger.LogError("Ключ API для Google Gemini не сконфигурирован."); + return null; + } + if (string.IsNullOrWhiteSpace(textToSummarize)) + { + _logger.LogWarning("Получен пустой текст для суммаризации."); + return null; // Нечего суммаризировать + } + + // Формируем URL + string requestUrl = string.Format(_geminiSettings.ApiEndpointFormat, _geminiSettings.ModelName, _apiKey); + _logger.LogInformation("Запрос саммари к Google Gemini API (Модель: {Model})...", _geminiSettings.ModelName); + + + // Формируем УЛУЧШЕННЫЙ промпт + var prompt = new StringBuilder(); + prompt.AppendLine("Ты - редактор, готовящий краткую новостную сводку по городу Новокузнецку для Telegram-дайджеста.") + .AppendLine("Проанализируй следующие тексты новостей, появившихся за последние 24 часа:") + .AppendLine("--- ТЕКСТЫ НОВОСТЕЙ НАЧАЛО ---") + .AppendLine(textToSummarize) + .AppendLine("--- ТЕКСТЫ НОВОСТЕЙ КОНЕЦ ---") + .AppendLine("\nТвоя задача:") + .AppendLine("1. Выделить 2-4 САМЫХ ВАЖНЫХ и интересных события.") + .AppendLine("2. Для каждого события сформулировать ОЧЕНЬ КРАТКОЕ описание (1-2 предложения максимум).") + .AppendLine("3. Представить результат в виде НЕНУМЕРОВАННОГО списка, используя эмоджи в качестве маркера для каждого пункта.") + .AppendLine("4. Использовать нейтральный и информативный тон. Без эмоций, оценок, приветствий и заключений.") + .AppendLine("5. Не добавляй заголовок к списку новостей, только сам список.") + .AppendLine("\nПример желаемого формата вывода:") + .AppendLine("- Краткое описание первого события.") + .AppendLine("- Краткое описание второго события.") + .AppendLine("\nСгенерируй сводку:") + ; + + + // Создаем тело запроса с помощью DTO + var requestDto = new GeminiRequestDto + { + Contents = new List + { + new ContentDto { Parts = new List { new PartDto { Text = prompt.ToString() } } } + }, + GenerationConfig = new GenerationConfigDto + { + Temperature = _geminiSettings.Temperature, + MaxOutputTokens = _geminiSettings.MaxOutputTokens + } + // Можно добавить SafetySettings, если нужно + }; + + // Сериализуем DTO в JSON + string jsonPayload = JsonConvert.SerializeObject(requestDto); + _logger.LogDebug("Тело запроса Gemini JSON: {Payload}", jsonPayload); // Осторожно, может быть большим + + var client = _httpClientFactory.CreateClient(); + try + { + // Создаем и отправляем POST-запрос + using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl); + request.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await client.SendAsync(request, cancellationToken); + + string responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Ошибка при запросе к Gemini API. Статус: {StatusCode}. Ответ: {ResponseBody}", response.StatusCode, responseBody); + return null; + } + + _logger.LogDebug("Ответ от Gemini API: {ResponseBody}", responseBody); + + // Десериализуем ответ + var responseDto = JsonConvert.DeserializeObject(responseBody); + + // Проверяем наличие кандидатов и текста + var candidate = responseDto?.Candidates?.FirstOrDefault(); + var generatedText = candidate?.Content?.Parts?.FirstOrDefault()?.Text; + + // Проверяем причину завершения (на случай блокировки по безопасности) + if (candidate?.FinishReason != null && candidate.FinishReason != "STOP") + { + _logger.LogWarning("Генерация Gemini завершилась с причиной: {FinishReason}", candidate.FinishReason); + // Дополнительно логируем safety ratings, если они есть + if (candidate.SafetyRatings != null && candidate.SafetyRatings.Any(sr => sr.Blocked ?? false)) + { + _logger.LogWarning("Генерация заблокирована по безопасности: {@SafetyRatings}", candidate.SafetyRatings); + } + if (responseDto?.PromptFeedback?.BlockReason != null) + { + _logger.LogWarning("Промпт заблокирован по безопасности: {BlockReason}", responseDto.PromptFeedback.BlockReason); + } + // В зависимости от причины, можно вернуть null или специальное сообщение + // return $"Не удалось сгенерировать саммари (причина: {candidate.FinishReason})"; + return null; // Возвращаем null, если генерация не завершилась нормально + } + + + if (string.IsNullOrWhiteSpace(generatedText)) + { + _logger.LogWarning("Gemini API вернул пустой результат или не удалось извлечь текст."); + return null; + } + + _logger.LogInformation("Саммари успешно получено от Gemini API."); + return generatedText.Trim(); // Возвращаем полученный текст, убрав лишние пробелы + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Ошибка HTTP при запросе к Gemini API."); + return null; + } + catch (JsonException ex) + { + _logger.LogError(ex, "Ошибка парсинга JSON ответа Gemini API."); + return null; + } + catch (OperationCanceledException) + { + _logger.LogInformation("Запрос саммари был отменен."); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Неожиданная ошибка при получении саммари от Gemini API."); + return null; + } + } + } +} \ No newline at end of file diff --git a/Services/ICryptoService.cs b/Services/ICryptoService.cs new file mode 100644 index 0000000..db999a9 --- /dev/null +++ b/Services/ICryptoService.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using System.Threading; +using DailyDigestWorker.Models; + +namespace DailyDigestWorker.Services +{ + public interface ICryptoService + { + Task GetCryptoRatesAsync(CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/Services/ICurrencyService.cs b/Services/ICurrencyService.cs new file mode 100644 index 0000000..45d0d92 --- /dev/null +++ b/Services/ICurrencyService.cs @@ -0,0 +1,12 @@ +using DailyDigestWorker.Models; + +namespace DailyDigestWorker.Services +{ + // Интерфейс для сервиса получения курсов валют + public interface ICurrencyService + { + // Асинхронный метод для получения данных + // Принимает CancellationToken на случай, если операцию нужно будет прервать + Task GetCurrencyRatesAsync(CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/Services/IDigestBuilderService.cs b/Services/IDigestBuilderService.cs new file mode 100644 index 0000000..8888075 --- /dev/null +++ b/Services/IDigestBuilderService.cs @@ -0,0 +1,23 @@ +using DailyDigestWorker.Models; // Для наших моделей данных + +namespace DailyDigestWorker.Services +{ + // Интерфейс для сервиса, который формирует текст дайджеста + public interface IDigestBuilderService + { + /// + /// Собирает текстовое представление дайджеста на основе полученных данных. + /// + /// Данные о курсах валют (или null). + /// Данные о курсах криптовалют (или null). + /// Данные о погоде (или null). + /// Саммари новостей (или null). + /// Отформатированный текст дайджеста (Markdown). + string BuildDigestText( + CurrencyData? currency, + CryptoData? crypto, + WeatherData? weather, + string? newsSummary // Пока будем передавать null + ); + } +} \ No newline at end of file diff --git a/Services/ISummarizationService.cs b/Services/ISummarizationService.cs new file mode 100644 index 0000000..2f94156 --- /dev/null +++ b/Services/ISummarizationService.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace DailyDigestWorker.Services +{ + public interface ISummarizationService + { + /// + /// Генерирует саммари для предоставленного текста с помощью AI. + /// + /// Текст для суммаризации. + /// Токен отмены. + /// Строка с саммари или null в случае ошибки. + Task SummarizeTextAsync(string textToSummarize, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/Services/ITelegramBotSender.cs b/Services/ITelegramBotSender.cs new file mode 100644 index 0000000..4fb7c8b --- /dev/null +++ b/Services/ITelegramBotSender.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using System.Threading; +using Telegram.Bot.Types.Enums; // Для ParseMode + +namespace DailyDigestWorker.Services +{ + // Интерфейс для сервиса отправки сообщений через Telegram Bot API + public interface ITelegramBotSender + { + /// + /// Отправляет текстовое сообщение в указанный чат. + /// + /// Текст сообщения. + /// Режим разметки (Markdown/Html). + /// Токен отмены. + /// True, если отправка инициирована успешно, иначе false. + Task SendMessageAsync(string messageText, ParseMode parseMode, CancellationToken cancellationToken); + + /// + /// Отправляет сообщение об ошибке в целевой чат. + /// + /// Текст ошибки. + /// Токен отмены. + /// True, если отправка инициирована успешно, иначе false. + Task SendErrorMessageAsync(string errorMessage, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/Services/ITelegramChannelReader.cs b/Services/ITelegramChannelReader.cs new file mode 100644 index 0000000..7576488 --- /dev/null +++ b/Services/ITelegramChannelReader.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace DailyDigestWorker.Services +{ + // Интерфейс для сервиса чтения сообщений из Telegram-канала + public interface ITelegramChannelReader + { + /// + /// Получает тексты последних сообщений из целевого канала. + /// + /// Максимальный возраст сообщений в часах. + /// Максимальное количество сообщений для проверки. + /// Токен отмены. + /// Список текстов сообщений (от старых к новым) или null в случае ошибки. + Task?> GetRecentNewsAsync(int maxAgeHours, int limit, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/Services/IWeatherService.cs b/Services/IWeatherService.cs new file mode 100644 index 0000000..d7cea3a --- /dev/null +++ b/Services/IWeatherService.cs @@ -0,0 +1,11 @@ +using DailyDigestWorker.Models; // Для WeatherData +using System.Threading.Tasks; +using System.Threading; + +namespace DailyDigestWorker.Services +{ + public interface IWeatherService + { + Task GetWeatherAsync(CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/Services/OpenWeatherMapService.cs b/Services/OpenWeatherMapService.cs new file mode 100644 index 0000000..27177e9 --- /dev/null +++ b/Services/OpenWeatherMapService.cs @@ -0,0 +1,108 @@ +using DailyDigestWorker.Configuration; // Для DataSourceUrls, ApiKeys +using DailyDigestWorker.Models; // Для WeatherData +using DailyDigestWorker.Models.OpenWeatherMap; // Для DTO классов +using Microsoft.Extensions.Options; // Для IOptions +using Newtonsoft.Json; // Для JsonConvert + +namespace DailyDigestWorker.Services +{ + public class OpenWeatherMapService : IWeatherService // Реализуем интерфейс + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly DataSourceUrls _dataSourceUrls; + private readonly ApiKeys _apiKeys; + + // Внедряем зависимости, включая ApiKeys + public OpenWeatherMapService( + IHttpClientFactory httpClientFactory, + IOptions dataSourceUrlsOptions, + IOptions apiKeysOptions, // Добавляем ApiKeys + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + _dataSourceUrls = dataSourceUrlsOptions.Value; + _apiKeys = apiKeysOptions.Value; // Сохраняем ApiKeys + } + + public async Task GetWeatherAsync(CancellationToken cancellationToken) + { + // Проверяем наличие ключа API + if (string.IsNullOrWhiteSpace(_apiKeys.OpenWeatherMap)) + { + _logger.LogError("Ключ API для OpenWeatherMap не сконфигурирован."); + return null; + } + + // Формируем URL, подставляя API ключ + string requestUrl = string.Format(_dataSourceUrls.OpenWeatherMap, _apiKeys.OpenWeatherMap); + _logger.LogInformation("Запрос погоды с OpenWeatherMap: {Url}", requestUrl); + + var client = _httpClientFactory.CreateClient(); + + try + { + HttpResponseMessage response = await client.GetAsync(requestUrl, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Ошибка при запросе к API OpenWeatherMap. Статус код: {StatusCode}", response.StatusCode); + // Попробуем прочитать тело ошибки, если есть + string errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError("Тело ошибки OpenWeatherMap: {ErrorBody}", errorBody); + return null; + } + + string jsonResponse = await response.Content.ReadAsStringAsync(cancellationToken); + + // Десериализуем JSON в наш DTO + var weatherResponseDto = JsonConvert.DeserializeObject(jsonResponse); + + // Проверяем основные данные + if (weatherResponseDto?.Main == null || weatherResponseDto.Weather == null || !weatherResponseDto.Weather.Any()) + { + _logger.LogWarning("Не удалось десериализовать основные данные из ответа OpenWeatherMap или они пусты."); + return null; + } + + // Извлекаем нужную информацию + var mainData = weatherResponseDto.Main; + var descriptionData = weatherResponseDto.Weather.First(); // Берем первое описание + + _logger.LogInformation("Погода успешно получена: {Description}, {Temperature}°C", descriptionData.Description, mainData.Temp); + + // Создаем и возвращаем нашу модель WeatherData + var resultData = new WeatherData + { + TemperatureC = mainData.Temp, + FeelsLikeC = mainData.FeelsLike, + Description = descriptionData.Description ?? "Нет данных", // Используем ?? для значения по умолчанию + Icon = descriptionData.Icon ?? "" + }; + + return resultData; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Ошибка HTTP при запросе к API OpenWeatherMap."); + return null; + } + catch (JsonException ex) + { + _logger.LogError(ex, "Ошибка парсинга JSON ответа OpenWeatherMap."); + return null; + } + catch (OperationCanceledException) + { + _logger.LogInformation("Запрос погоды был отменен."); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Неожиданная ошибка при получении погоды OpenWeatherMap."); + return null; + } + } + } +} \ No newline at end of file diff --git a/Services/TelegramBotSender.cs b/Services/TelegramBotSender.cs new file mode 100644 index 0000000..2d80b2d --- /dev/null +++ b/Services/TelegramBotSender.cs @@ -0,0 +1,84 @@ +using DailyDigestWorker.Configuration; // Для BotConfiguration +using Microsoft.Extensions.Options; // Для IOptions +using Telegram.Bot; // Для ITelegramBotClient +using Telegram.Bot.Types.Enums; // Для ParseMode + +namespace DailyDigestWorker.Services +{ + public class TelegramBotSender : ITelegramBotSender // Реализуем интерфейс + { + private readonly ILogger _logger; + private readonly ITelegramBotClient _botClient; + private readonly BotConfiguration _botConfig; + private readonly long? _targetChatId; // Храним распарсенный ID + + // Внедряем зависимости + public TelegramBotSender( + ILogger logger, + ITelegramBotClient botClient, // Внедряем клиент бота + IOptions botOptions) // Внедряем настройки + { + _logger = logger; + _botClient = botClient; + _botConfig = botOptions.Value; // Сохраняем настройки + + // Пытаемся распарсить TargetChatId один раз в конструкторе + if (!string.IsNullOrWhiteSpace(_botConfig.TargetChatId) && + long.TryParse(_botConfig.TargetChatId, out long parsedId)) + { + _targetChatId = parsedId; + _logger.LogInformation("TelegramBotSender настроен для отправки в чат ID: {ChatId}", _targetChatId); + } + else + { + _targetChatId = null; // Не удалось распарсить или не задан + _logger.LogWarning("TargetChatId не сконфигурирован или некорректен в настройках. Отправка сообщений будет невозможна."); + } + } + + public async Task SendMessageAsync(string messageText, ParseMode parseMode, CancellationToken cancellationToken) + { + if (!_targetChatId.HasValue) + { + _logger.LogWarning("Попытка отправить сообщение, но TargetChatId не настроен."); + return false; + } + if (string.IsNullOrWhiteSpace(messageText)) + { + _logger.LogWarning("Попытка отправить пустое сообщение."); + return false; + } + + try + { + _logger.LogInformation("Отправка сообщения в чат {ChatId}...", _targetChatId.Value); + await _botClient.SendMessage( + chatId: _targetChatId.Value, + text: messageText, + parseMode: parseMode, + linkPreviewOptions: new Telegram.Bot.Types.LinkPreviewOptions { IsDisabled = true }, // <-- ИСПОЛЬЗОВАТЬ ЭТО + cancellationToken: cancellationToken); + _logger.LogInformation("Сообщение успешно отправлено (инициировано)."); + return true; + } + catch (OperationCanceledException) + { + _logger.LogInformation("Отправка сообщения отменена."); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Ошибка при отправке сообщения в Telegram чат {ChatId}", _targetChatId.Value); + return false; + } + } + + public async Task SendErrorMessageAsync(string errorMessage, CancellationToken cancellationToken) + { + // Используем тот же метод отправки, но без специфичного ParseMode (или можно указать None/Default) + // Можно добавить префикс к сообщению об ошибке + string fullMessage = $"⚠️ **Ошибка при создании дайджеста:**\n{errorMessage}"; + return await SendMessageAsync(fullMessage, ParseMode.Markdown, cancellationToken); // Используем Markdown для выделения + } + } +} \ No newline at end of file diff --git a/Services/TelegramChannelReader.cs b/Services/TelegramChannelReader.cs new file mode 100644 index 0000000..37aa8f2 --- /dev/null +++ b/Services/TelegramChannelReader.cs @@ -0,0 +1,132 @@ +using DailyDigestWorker.Configuration; // Для TelegramClientSettings +using Microsoft.Extensions.Options; // Для IOptions +using TL; // Основное пространство имен WTelegramClient для типов Telegram API +using WTelegram; // Для Client + +namespace DailyDigestWorker.Services +{ + public class TelegramChannelReader : ITelegramChannelReader // Реализуем интерфейс + { + private readonly ILogger _logger; + private readonly Client _client; // Наш Singleton WTelegramClient + private readonly TelegramClientSettings _settings; + private InputPeer? _targetPeer; // Кэшированный InputPeer канала + + // Внедряем зависимости + public TelegramChannelReader( + ILogger logger, + Client client, // Внедряем WTelegramClient + IOptions settingsOptions) + { + _logger = logger; + _client = client; + _settings = settingsOptions.Value; + } + + public async Task?> GetRecentNewsAsync(int maxAgeHours, int limit, CancellationToken cancellationToken) + { + _logger.LogInformation("Попытка чтения новостей из канала {ChannelUsername}...", _settings.TargetChannelUsername); + + try + { + _logger.LogDebug("Шаг 1: Проверка/выполнение авторизации пользователя..."); + // Добавляем try-catch вокруг LoginUserIfNeeded для более детального логгирования + User? currentUser = null; + try + { + currentUser = await _client.LoginUserIfNeeded(); // Может запросить ввод в консоль! + } + catch (Exception loginEx) + { + _logger.LogError(loginEx, "Ошибка во время выполнения LoginUserIfNeeded."); + return null; // Выходим, если логин не удался + } + + if (currentUser == null) + { + _logger.LogError("Авторизация не удалась (LoginUserIfNeeded вернул null или выбросил исключение)."); + return null; + } + _logger.LogInformation("Шаг 1 Успех: Авторизация как {UserFirstName} (ID: {UserId})", currentUser.first_name, currentUser.id); + + + // 2. Найти канал (получить InputPeer) - кэшируем результат + if (_targetPeer == null) + { + _logger.LogDebug("Шаг 2: Поиск InputPeer для канала {ChannelUsername}...", _settings.TargetChannelUsername); + var resolved = await _client.Contacts_ResolveUsername(_settings.TargetChannelUsername); + if (resolved?.UserOrChat is Channel channel) + { + _targetPeer = channel.ToInputPeer(); + _logger.LogInformation("Шаг 2 Успех: Найден InputPeer для канала '{ChannelTitle}' (ID: {ChannelId})", channel.title, channel.id); + } + else + { + _logger.LogError("Шаг 2 Ошибка: Не удалось найти канал по юзернейму {ChannelUsername} или это не канал. Resolved: {@Resolved}", _settings.TargetChannelUsername, resolved); + return null; + } + } + else + { + _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)."); + return null; + } + _logger.LogInformation("Шаг 3 Успех: Получено {Count} сообщений/элементов в истории.", history.Messages.Length); + + + // 4. Отфильтровать сообщения по дате и извлечь текст + _logger.LogDebug("Шаг 4: Фильтрация сообщений новее {CutoffDateUtc} UTC...", DateTime.UtcNow.AddHours(-maxAgeHours)); + var newsTexts = new List(); + var cutoffDate = DateTime.UtcNow.AddHours(-maxAgeHours); + + foreach (var msgBase in history.Messages) + { + if (msgBase is Message message) + { + if (message.Date >= cutoffDate) + { + 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.LogInformation("Шаг 4 Успех: Найдено {Count} новостных сообщений за последние {Hours} часов.", newsTexts.Count, maxAgeHours); + + newsTexts.Reverse(); + return newsTexts; + } + // ... (остальные catch блоки без изменений) ... + catch (RpcException e) + { + _logger.LogError(e, "Ошибка Telegram API ({ErrorCode}): {ErrorMessage}", e.Code, e.Message); + if (e.Code == 420) + { + _logger.LogWarning("Получен FloodWait на {Seconds} секунд. Пропускаем цикл чтения новостей.", e.X); + } + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Неожиданная ошибка при чтении новостей из Telegram канала."); + return null; + } + } + } +} \ No newline at end of file diff --git a/Worker.cs b/Worker.cs new file mode 100644 index 0000000..151f8db --- /dev/null +++ b/Worker.cs @@ -0,0 +1,267 @@ +using DailyDigestWorker.Configuration; // Наша конфигурация +using DailyDigestWorker.Services; // Наши сервисы +using Microsoft.Extensions.Options; // Для IOptions +using System.Globalization; // Для TimeSpan.ParseExact и других настроек культуры +using Telegram.Bot.Types.Enums; // Для ParseMode + +namespace DailyDigestWorker +{ + public class Worker : BackgroundService + { + private readonly ILogger _logger; + // Храним список времен запуска как TimeSpan + private readonly List _runTimes; + private readonly IServiceProvider _serviceProvider; + + // Внедряем зависимости через конструктор + public Worker(ILogger logger, + IOptions schedulingSettings, + IServiceProvider serviceProvider) + { + _logger = logger; + _serviceProvider = serviceProvider; + _runTimes = new List(); + + // Проверяем наличие Value и списка перед использованием + var timeStrings = schedulingSettings.Value?.RunAtTimesLocal; // Теперь может быть null + + if (timeStrings != null) // Обрабатываем только если список задан в конфиге + { + foreach (var timeStr in timeStrings) + { + if (TimeSpan.TryParseExact(timeStr?.Trim(), @"hh\:mm\:ss", CultureInfo.InvariantCulture, out var timeSpan)) + { + _runTimes.Add(timeSpan); + } + else + { + _logger.LogWarning("Не удалось распознать время '{TimeString}' в настройках Scheduling:RunAtTimesLocal. Используйте формат HH:mm:ss.", timeStr); + } + } + _runTimes = _runTimes.Distinct().OrderBy(t => t).ToList(); + } + + // Логика обработки пустого или отсутствующего списка остается прежней + if (!_runTimes.Any()) + { + _logger.LogError("В конфигурации не указано ни одного корректного времени запуска! Worker не сможет автоматически запускаться."); + // Важно: убедитесь, что здесь НЕТ строки, добавляющей 8 утра по умолчанию, + // если вы хотите, чтобы бот НЕ работал без явной конфигурации времени. + } + + _logger.LogInformation("Worker инициализирован. Дайджест будет запускаться ежедневно в: {RunTimes}", + _runTimes.Any() ? string.Join(", ", _runTimes.Select(t => t.ToString(@"hh\:mm\:ss"))) : "НЕТ ВРЕМЕН ЗАПУСКА"); + } + + // Основной метод, который выполняется в фоне + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Worker запущен в: {time}", DateTimeOffset.Now); + + // Проверяем наличие времен запуска перед стартом цикла + if (!_runTimes.Any()) + { + _logger.LogError("Нет сконфигурированных времен запуска. Worker завершает работу."); + return; // Останавливаем выполнение, если нет времен + } + + while (!stoppingToken.IsCancellationRequested) + { + // Рассчитываем задержку до ближайшего времени запуска + TimeSpan delay = CalculateDelayUntilNearestRun(_runTimes); + + _logger.LogInformation("Следующий запуск дайджеста запланирован через: {Delay} (в {TargetTime})", + delay, DateTime.Now.Add(delay).ToString("yyyy-MM-dd HH:mm:ss")); + + try + { + // Ожидаем до следующего времени запуска, прерывая ожидание, если придет сигнал остановки + // Если задержка 0 или меньше (опоздали), Task.Delay корректно обработает это и не будет ждать. + await Task.Delay(delay, stoppingToken); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Запрос на остановку получен во время ожидания."); + break; // Выходим из цикла while + } + + // Проверяем еще раз перед выполнением задачи, на случай если остановка пришла точно в момент срабатывания + if (stoppingToken.IsCancellationRequested) break; + + // --- Выполнение основной задачи --- + _logger.LogInformation("Запуск процесса создания дайджеста в: {time}", DateTimeOffset.Now); + using (var scope = _serviceProvider.CreateScope()) + { + // --- Получаем ВСЕ необходимые сервисы в начале scope --- + var currencyService = scope.ServiceProvider.GetRequiredService(); + var cryptoService = scope.ServiceProvider.GetRequiredService(); + var weatherService = scope.ServiceProvider.GetRequiredService(); + var digestBuilder = scope.ServiceProvider.GetRequiredService(); + var telegramSender = scope.ServiceProvider.GetRequiredService(); + var newsReaderService = scope.ServiceProvider.GetRequiredService(); + var summarizationService = scope.ServiceProvider.GetRequiredService(); + + // Константы для чтения новостей (можно вынести в конфиг) + const int NewsMaxAgeHours = 24; + const int NewsFetchLimit = 50; // Сколько последних сообщений проверить + + string? newsSummary = null; // Инициализируем переменную для саммари + + try + { + // --- 1. Сбор данных --- + _logger.LogInformation("Начало сбора данных для дайджеста..."); + var currencyTask = currencyService.GetCurrencyRatesAsync(stoppingToken); + var cryptoTask = cryptoService.GetCryptoRatesAsync(stoppingToken); + var weatherTask = weatherService.GetWeatherAsync(stoppingToken); + var newsTask = newsReaderService.GetRecentNewsAsync(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.5 Генерация саммари новостей (если есть новости) --- + if (rawNewsTexts != null && rawNewsTexts.Any()) + { + _logger.LogInformation("Получено {NewsCount} текстов новостей. Запуск суммаризации...", rawNewsTexts.Count); + try + { + string combinedNewsText = string.Join("\n\n---\n\n", rawNewsTexts); + newsSummary = await summarizationService.SummarizeTextAsync(combinedNewsText, stoppingToken); + + if (newsSummary != null) + { + _logger.LogInformation("Саммари новостей успешно получено."); + _logger.LogDebug("Текст саммари:\n{NewsSummary}", newsSummary); + } + else + { + _logger.LogWarning("Сервис суммаризации вернул null."); + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("Операция суммаризации была отменена."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Ошибка во время вызова сервиса суммаризации."); + } + } + else + { + _logger.LogInformation("Нет текстов новостей для суммаризации."); + } + + // --- 2. Формирование текста дайджеста --- + _logger.LogInformation("Формирование текста дайджеста..."); + string digestText = digestBuilder.BuildDigestText(currencyData, cryptoData, weatherData, newsSummary); + _logger.LogDebug("Сформированный текст получен от DigestBuilder."); + + // --- 3. Отправка дайджеста --- + if (!string.IsNullOrWhiteSpace(digestText)) + { + _logger.LogInformation("Отправка дайджеста..."); + bool sent = await telegramSender.SendMessageAsync(digestText, ParseMode.Markdown, stoppingToken); + if (!sent) + { + _logger.LogWarning("Не удалось инициировать отправку дайджеста."); + } + } + else + { + _logger.LogWarning("Текст дайджеста пуст, отправка не производится."); + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("Запрос на остановку получен во время выполнения основной задачи."); + break; // Выходим из while + } + catch (Exception ex) + { // Ловим ошибки на уровне всего цикла задачи + _logger.LogError(ex, "Произошла КРИТИЧЕСКАЯ ошибка во время выполнения задачи создания дайджеста."); + try + { + await telegramSender.SendErrorMessageAsync($"Критическая ошибка в Worker: {ex.Message}", CancellationToken.None); + } + catch (Exception sendEx) + { + _logger.LogError(sendEx, "Не удалось отправить сообщение о КРИТИЧЕСКОЙ ошибке в Telegram."); + } + // Не добавляем здесь Delay, чтобы не задерживать следующий цикл после критической ошибки. + // Worker перейдет к расчету следующего запуска. + } + } // Конец scope + + _logger.LogInformation("Процесс создания дайджеста завершен. Расчет следующего запуска."); + + // Небольшая пауза (~1 сек) после выполнения, чтобы гарантированно перейти на следующую секунду + // и избежать повторного срабатывания в ту же минуту, если расчет времени даст очень маленькую задержку. + try + { + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Запрос на остановку получен во время короткой паузы после выполнения."); + break; + } + + } // Конец while + + _logger.LogInformation("Worker останавливается."); + } + + // Метод для расчета задержки до ближайшего времени запуска из списка + private TimeSpan CalculateDelayUntilNearestRun(List runTimes) + { + var now = DateTime.Now; + var today = now.Date; + TimeSpan currentTimeOfDay = now.TimeOfDay; + + // Ищем ближайшее время запуска СЕГОДНЯ, которое еще не прошло (или равно текущему времени с точностью до секунды) + // Добавляем небольшую дельту (например, 1 секунду) к currentTimeOfDay, чтобы не пропустить запуск, + // который должен был произойти точно в текущую секунду. + TimeSpan? nextRunTimeToday = runTimes + .Where(t => t >= currentTimeOfDay) + .Cast() + .FirstOrDefault(); // Список уже отсортирован, берем первое подходящее + + DateTime nextRunDateTime; + + if (nextRunTimeToday.HasValue) + { + // Если нашли время сегодня, планируем на сегодня + nextRunDateTime = today + nextRunTimeToday.Value; + _logger.LogDebug("Ближайшее время запуска сегодня: {RunTime:yyyy-MM-dd HH:mm:ss}", nextRunDateTime); + } + else + { + // Если сегодня подходящего времени нет, берем самое раннее время из списка и планируем на ЗАВТРА + // runTimes гарантированно не пустой и отсортирован (проверено в конструкторе) + var earliestTimeTomorrow = runTimes.First(); + nextRunDateTime = today.AddDays(1) + earliestTimeTomorrow; + _logger.LogDebug("Все времена запуска сегодня прошли. Ближайшее время завтра: {RunTime:yyyy-MM-dd HH:mm:ss}", nextRunDateTime); + } + + var delay = nextRunDateTime - now; + + // Если задержка отрицательная или очень маленькая (меньше ~0), возвращаем Zero. + return delay < TimeSpan.Zero ? TimeSpan.Zero : delay; + } + + + public override Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Worker StopAsync вызван."); + // Можно добавить код для корректного завершения (например, Logout для WTelegramClient) + return base.StopAsync(cancellationToken); + } + } +} \ No newline at end of file diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..cf6eec0 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,45 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", // Уровень логирования по умолчанию + "Microsoft.Hosting.Lifetime": "Information" // Логирование событий жизненного цикла хоста + } + }, + "BotConfiguration": { + "BotToken": "7370439998:AAFKMvzbkv3Vh-E477De7_QKrTCQblI7wlc", // Сюда вставьте токен вашего Telegram бота (от BotFather) + "TargetChatId": "-1002642759581" // Сюда вставьте ID чата, куда бот будет слать дайджест (ваш ID или ID группы) + }, + "TelegramClient": { + "ApiId": 11104889, // Замените на ваш API ID (число!) с my.telegram.org + "ApiHash": "81f467fd132d401fe69eaf45ae4275eb", // Замените на ваш API Hash (строка!) с my.telegram.org + "PhoneNumber": "+79069302883", // Ваш номер телефона для авторизации Client API + "SessionPath": "telegram_session.dat", // Имя файла для сохранения сессии Client API + "TargetChannelUsername": "topor" // Юзернейм (без @) канала для чтения новостей + }, + "ApiKeys": { + "OpenWeatherMap": "1425466ae8a0a723bd3300526ced0fff", // Ваш API ключ от OpenWeatherMap + "Gemini": "AIzaSyClYI2zhYbAZgtT300JHAfYjIiyxrMV6W8" // Ваш API ключ от Google Gemini (или Vertex AI) + }, + "DataSourceUrls": { + "Cbr": "https://www.cbr.ru/scripts/XML_daily.asp", + "CoinGecko": "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin%2Cethereum&vs_currencies=usd%2Crub", // Добавил ETH + "OpenWeatherMap": "https://api.openweathermap.org/data/2.5/weather?q=Novokuznetsk,RU&appid={0}&units=metric&lang=ru" // {0} будет заменен на API ключ OWM + // Для Gemini API URL может быть не нужен, если используется клиентская библиотека Google + }, + "Scheduling": { + // Используем массив строк для указания времени + "RunAtTimesLocal": [ + "09:00:00", + "14:00:00", + "18:00:00", + "22:00:00" + ] + }, + "GeminiSettings": { + "ModelName": "gemini-1.5-flash-latest", // <--- ИЗМЕНИТЬ ЗДЕСЬ + "Temperature": 0.6, + "MaxOutputTokens": 1024 + // Убедитесь, что ApiEndpointFormat использует v1beta или уберите его, чтобы использовался дефолтный из кода + // "ApiEndpointFormat": "https://generativelanguage.googleapis.com/v1beta/models/{0}:generateContent?key={1}" + } +} \ No newline at end of file diff --git a/telegram_session.dat b/telegram_session.dat new file mode 100644 index 0000000..378c038 Binary files /dev/null and b/telegram_session.dat differ