From dc6c96f1fcadf828c96d7fb9372b8f27243e0e21 Mon Sep 17 00:00:00 2001 From: Professional Date: Sat, 12 Apr 2025 00:27:03 +0700 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D1=8C=D1=82?= =?UTF-8?q?=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Configuration/ApiKeys.cs | 8 + Configuration/BotConfiguration.cs | 8 + Configuration/DataSourceUrls.cs | 9 + Configuration/GeminiSettings.cs | 13 + Configuration/SchedulingSettings.cs | 8 + Configuration/TelegramClientSettings.cs | 11 + DailyDigestWorker.csproj | 19 ++ DailyDigestWorker.sln | 25 ++ Models/CryptoData.cs | 12 + Models/CurrencyData.cs | 10 + Models/Gemini/GeminiRequestDto.cs | 49 ++++ Models/Gemini/GeminiResponseDto.cs | 49 ++++ Models/OpenWeatherMapDtos.cs | 37 +++ Models/WeatherData.cs | 31 +++ Program.cs | 101 +++++++ Properties/launchSettings.json | 12 + Services/CbrCurrencyService.cs | 132 +++++++++ Services/CoinGeckoCryptoService.cs | 97 +++++++ Services/DigestBuilderService.cs | 113 ++++++++ Services/GoogleGeminiSummarizationService.cs | 164 ++++++++++++ Services/ICryptoService.cs | 11 + Services/ICurrencyService.cs | 12 + Services/IDigestBuilderService.cs | 23 ++ Services/ISummarizationService.cs | 16 ++ Services/ITelegramBotSender.cs | 27 ++ Services/ITelegramChannelReader.cs | 19 ++ Services/IWeatherService.cs | 11 + Services/OpenWeatherMapService.cs | 108 ++++++++ Services/TelegramBotSender.cs | 84 ++++++ Services/TelegramChannelReader.cs | 132 +++++++++ Worker.cs | 267 +++++++++++++++++++ appsettings.Development.json | 8 + appsettings.json | 45 ++++ telegram_session.dat | Bin 0 -> 31800 bytes 34 files changed, 1671 insertions(+) create mode 100644 Configuration/ApiKeys.cs create mode 100644 Configuration/BotConfiguration.cs create mode 100644 Configuration/DataSourceUrls.cs create mode 100644 Configuration/GeminiSettings.cs create mode 100644 Configuration/SchedulingSettings.cs create mode 100644 Configuration/TelegramClientSettings.cs create mode 100644 DailyDigestWorker.csproj create mode 100644 DailyDigestWorker.sln create mode 100644 Models/CryptoData.cs create mode 100644 Models/CurrencyData.cs create mode 100644 Models/Gemini/GeminiRequestDto.cs create mode 100644 Models/Gemini/GeminiResponseDto.cs create mode 100644 Models/OpenWeatherMapDtos.cs create mode 100644 Models/WeatherData.cs create mode 100644 Program.cs create mode 100644 Properties/launchSettings.json create mode 100644 Services/CbrCurrencyService.cs create mode 100644 Services/CoinGeckoCryptoService.cs create mode 100644 Services/DigestBuilderService.cs create mode 100644 Services/GoogleGeminiSummarizationService.cs create mode 100644 Services/ICryptoService.cs create mode 100644 Services/ICurrencyService.cs create mode 100644 Services/IDigestBuilderService.cs create mode 100644 Services/ISummarizationService.cs create mode 100644 Services/ITelegramBotSender.cs create mode 100644 Services/ITelegramChannelReader.cs create mode 100644 Services/IWeatherService.cs create mode 100644 Services/OpenWeatherMapService.cs create mode 100644 Services/TelegramBotSender.cs create mode 100644 Services/TelegramChannelReader.cs create mode 100644 Worker.cs create mode 100644 appsettings.Development.json create mode 100644 appsettings.json create mode 100644 telegram_session.dat 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 0000000000000000000000000000000000000000..378c0384dd5242df5eb68075398accd38e445c52 GIT binary patch literal 31800 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 literal 0 HcmV?d00001