Добавьте файлы проекта.

This commit is contained in:
Professional 2025-04-12 00:27:03 +07:00
parent b1d3ffa51d
commit dc6c96f1fc
34 changed files with 1671 additions and 0 deletions

8
Configuration/ApiKeys.cs Normal file
View File

@ -0,0 +1,8 @@
namespace DailyDigestWorker.Configuration
{
public class ApiKeys
{
public required string OpenWeatherMap { get; set; }
public required string Gemini { get; set; }
}
}

View File

@ -0,0 +1,8 @@
namespace DailyDigestWorker.Configuration
{
public class BotConfiguration
{
public required string BotToken { get; set; } // Используем required для обязательных полей (или string?)
public required string TargetChatId { get; set; }
}
}

View File

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

View File

@ -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<SafetySettingDto> SafetySettings { get; set; }
}
}

View File

@ -0,0 +1,8 @@
namespace DailyDigestWorker.Configuration
{
public class SchedulingSettings
{
// Убираем значение по умолчанию. Список будет null, если не задан в конфиге.
public List<string>? RunAtTimesLocal { get; set; }
}
}

View File

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

19
DailyDigestWorker.csproj Normal file
View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-DailyDigestWorker-14a227e9-5097-46b5-8901-6f6dab4c4a9c</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="4.0.1" />
<PackageReference Include="Telegram.Bot" Version="22.4.4" />
<PackageReference Include="WTelegramClient" Version="4.3.3" />
</ItemGroup>
</Project>

25
DailyDigestWorker.sln Normal file
View File

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

12
Models/CryptoData.cs Normal file
View File

@ -0,0 +1,12 @@
using System.Collections.Generic; // Для Dictionary
namespace DailyDigestWorker.Models
{
// Модель для хранения курсов криптовалют
public class CryptoData
{
// Используем словарь: Идентификатор крипты (bitcoin, ethereum) -> Внутренний словарь (Валюта -> Курс)
public Dictionary<string, Dictionary<string, decimal>> CryptoRates { get; } = new Dictionary<string, Dictionary<string, decimal>>();
// Пример: {"bitcoin": {"usd": 60000, "rub": 5000000}, "ethereum": ... }
}
}

10
Models/CurrencyData.cs Normal file
View File

@ -0,0 +1,10 @@
namespace DailyDigestWorker.Models
{
// Простая модель для хранения курсов валют
public class CurrencyData
{
// Используем словарь: Код валюты (USD/EUR) -> Курс (decimal)
public Dictionary<string, decimal> Rates { get; } = new Dictionary<string, decimal>();
public DateTime Date { get; set; } // Дата, на которую установлены курсы
}
}

View File

@ -0,0 +1,49 @@
using Newtonsoft.Json;
using System.Collections.Generic;
namespace DailyDigestWorker.Models.Gemini
{
public class GeminiRequestDto
{
[JsonProperty("contents")]
public List<ContentDto> Contents { get; set; } = new List<ContentDto>();
// Опциональные параметры для контроля генерации
[JsonProperty("generationConfig", NullValueHandling = NullValueHandling.Ignore)]
public GenerationConfigDto? GenerationConfig { get; set; }
// Опциональные параметры безопасности
[JsonProperty("safetySettings", NullValueHandling = NullValueHandling.Ignore)]
public List<SafetySettingDto>? SafetySettings { get; set; }
}
public class ContentDto
{
[JsonProperty("parts")]
public List<PartDto> Parts { get; set; } = new List<PartDto>();
}
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"
}
}

View File

@ -0,0 +1,49 @@
using Newtonsoft.Json;
using System.Collections.Generic;
namespace DailyDigestWorker.Models.Gemini
{
public class GeminiResponseDto
{
[JsonProperty("candidates")]
public List<CandidateDto>? 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<SafetyRatingDto>? 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<SafetyRatingDto>? 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; }
}
}

View File

@ -0,0 +1,37 @@
using Newtonsoft.Json; // Используем атрибуты JsonProperty
namespace DailyDigestWorker.Models.OpenWeatherMap
{
// Основной класс ответа API
public class WeatherResponseDto
{
[JsonProperty("weather")]
public List<WeatherDescriptionDto>? 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; } // Ощущаемая температура
}
}

31
Models/WeatherData.cs Normal file
View File

@ -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
_ => "" // Неизвестная иконка
};
}
}

101
Program.cs Normal file
View File

@ -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<T>
builder.Services.Configure<BotConfiguration>(builder.Configuration.GetSection("BotConfiguration"));
builder.Services.Configure<TelegramClientSettings>(builder.Configuration.GetSection("TelegramClient"));
builder.Services.Configure<ApiKeys>(builder.Configuration.GetSection("ApiKeys"));
builder.Services.Configure<DataSourceUrls>(builder.Configuration.GetSection("DataSourceUrls"));
builder.Services.Configure<GeminiSettings>(builder.Configuration.GetSection("GeminiSettings"));
// Îñîáàÿ îáðàáîòêà äëÿ âðåìåíè â SchedulingSettings
builder.Services.Configure<SchedulingSettings>(builder.Configuration.GetSection("Scheduling"));
// --- Ðåãèñòðàöèÿ ñåðâèñîâ (Dependency Injection) ---
// 1. Ðåãèñòðèðóåì HttpClientFactory (äëÿ çàïðîñîâ ê API ïîãîäû, âàëþò, êðèïòû, Gemini)
builder.Services.AddHttpClient();
// 2. Ðåãèñòðèðóåì Telegram Bot Client (äëÿ ÎÒÏÐÀÂÊÈ ñîîáùåíèé)
// Ìû ðåãèñòðèðóåì åãî êàê Singleton, òàê êàê êëèåíò ïîòîêîáåçîïàñåí è äîëæåí áûòü îäèí
builder.Services.AddSingleton<ITelegramBotClient>(sp => // sp - Service Provider
{
// Ïîëó÷àåì êîíôèãóðàöèþ áîòà ÷åðåç IOptions
var botConfig = sp.GetRequiredService<IOptions<BotConfiguration>>().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<IOptions<TelegramClientSettings>>().Value;
var logger = sp.GetRequiredService<ILogger<WTelegram.Client>>(); // Ïîëó÷àåì ëîããåð
// Ôóíêöèÿ äëÿ ïîëó÷åíèÿ êîíôèãóðàöèîííûõ äàííûõ äëÿ 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<ICurrencyService, CbrCurrencyService>();
builder.Services.AddTransient<ICryptoService, CoinGeckoCryptoService>();
builder.Services.AddTransient<IWeatherService, OpenWeatherMapService>();
// 5. Ðåãèñòðèðóåì ñåðâèñû äëÿ îáðàáîòêè è îòïðàâêè
builder.Services.AddTransient<IDigestBuilderService, DigestBuilderService>();
builder.Services.AddTransient<ITelegramBotSender, TelegramBotSender>();
builder.Services.AddTransient<ITelegramChannelReader, TelegramChannelReader>();
builder.Services.AddTransient<ISummarizationService, GoogleGeminiSummarizationService>();// <-- ÄÎÁÀÂÈÒÜ ÝÒÓ ÑÒÐÎÊÓ
// 6. Ðåãèñòðèðóåì íàø îñíîâíîé Worker Service (ýòà ñòðîêà óæå äîëæíà áûòü)
builder.Services.AddHostedService<Worker>();
// --- Ñáîðêà è çàïóñê õîñòà ---
var host = builder.Build();
host.Run();

View File

@ -0,0 +1,12 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"DailyDigestWorker": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -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<CbrCurrencyService> _logger;
private readonly DataSourceUrls _dataSourceUrls;
// Внедряем зависимости
public CbrCurrencyService(
IHttpClientFactory httpClientFactory,
IOptions<DataSourceUrls> dataSourceUrlsOptions,
ILogger<CbrCurrencyService> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
_dataSourceUrls = dataSourceUrlsOptions.Value; // Получаем URL из настроек
}
public async Task<CurrencyData?> 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;
}
}
}
}

View File

@ -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<CoinGeckoCryptoService> _logger;
private readonly DataSourceUrls _dataSourceUrls;
// Внедряем зависимости
public CoinGeckoCryptoService(
IHttpClientFactory httpClientFactory,
IOptions<DataSourceUrls> dataSourceUrlsOptions,
ILogger<CoinGeckoCryptoService> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
_dataSourceUrls = dataSourceUrlsOptions.Value; // Получаем URL из настроек
}
public async Task<CryptoData?> 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<Dictionary<string, Dictionary<string, decimal>>>(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;
}
}
}
}

View File

@ -0,0 +1,113 @@
using DailyDigestWorker.Models;
using System.Globalization;
using System.Text;
namespace DailyDigestWorker.Services
{
public class DigestBuilderService : IDigestBuilderService
{
private readonly ILogger<DigestBuilderService> _logger;
public DigestBuilderService(ILogger<DigestBuilderService> 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<string>();
// Сортируем валюты (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;
}
}
}

View File

@ -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<GoogleGeminiSummarizationService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ApiKeys _apiKeys;
private readonly GeminiSettings _geminiSettings;
private readonly string _apiKey;
// Внедряем зависимости
public GoogleGeminiSummarizationService(
ILogger<GoogleGeminiSummarizationService> logger,
IHttpClientFactory httpClientFactory,
IOptions<ApiKeys> apiKeysOptions,
IOptions<GeminiSettings> geminiSettingsOptions)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_apiKeys = apiKeysOptions.Value;
_geminiSettings = geminiSettingsOptions.Value;
_apiKey = _apiKeys.Gemini; // Сохраняем ключ для удобства
}
public async Task<string?> 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<ContentDto>
{
new ContentDto { Parts = new List<PartDto> { 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<GeminiResponseDto>(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;
}
}
}
}

View File

@ -0,0 +1,11 @@
using System.Threading.Tasks;
using System.Threading;
using DailyDigestWorker.Models;
namespace DailyDigestWorker.Services
{
public interface ICryptoService
{
Task<CryptoData?> GetCryptoRatesAsync(CancellationToken cancellationToken);
}
}

View File

@ -0,0 +1,12 @@
using DailyDigestWorker.Models;
namespace DailyDigestWorker.Services
{
// Интерфейс для сервиса получения курсов валют
public interface ICurrencyService
{
// Асинхронный метод для получения данных
// Принимает CancellationToken на случай, если операцию нужно будет прервать
Task<CurrencyData?> GetCurrencyRatesAsync(CancellationToken cancellationToken);
}
}

View File

@ -0,0 +1,23 @@
using DailyDigestWorker.Models; // Для наших моделей данных
namespace DailyDigestWorker.Services
{
// Интерфейс для сервиса, который формирует текст дайджеста
public interface IDigestBuilderService
{
/// <summary>
/// Собирает текстовое представление дайджеста на основе полученных данных.
/// </summary>
/// <param name="currency">Данные о курсах валют (или null).</param>
/// <param name="crypto">Данные о курсах криптовалют (или null).</param>
/// <param name="weather">Данные о погоде (или null).</param>
/// <param name="newsSummary">Саммари новостей (или null).</param>
/// <returns>Отформатированный текст дайджеста (Markdown).</returns>
string BuildDigestText(
CurrencyData? currency,
CryptoData? crypto,
WeatherData? weather,
string? newsSummary // Пока будем передавать null
);
}
}

View File

@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
namespace DailyDigestWorker.Services
{
public interface ISummarizationService
{
/// <summary>
/// Генерирует саммари для предоставленного текста с помощью AI.
/// </summary>
/// <param name="textToSummarize">Текст для суммаризации.</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Строка с саммари или null в случае ошибки.</returns>
Task<string?> SummarizeTextAsync(string textToSummarize, CancellationToken cancellationToken);
}
}

View File

@ -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
{
/// <summary>
/// Отправляет текстовое сообщение в указанный чат.
/// </summary>
/// <param name="messageText">Текст сообщения.</param>
/// <param name="parseMode">Режим разметки (Markdown/Html).</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>True, если отправка инициирована успешно, иначе false.</returns>
Task<bool> SendMessageAsync(string messageText, ParseMode parseMode, CancellationToken cancellationToken);
/// <summary>
/// Отправляет сообщение об ошибке в целевой чат.
/// </summary>
/// <param name="errorMessage">Текст ошибки.</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>True, если отправка инициирована успешно, иначе false.</returns>
Task<bool> SendErrorMessageAsync(string errorMessage, CancellationToken cancellationToken);
}
}

View File

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

View File

@ -0,0 +1,11 @@
using DailyDigestWorker.Models; // Для WeatherData
using System.Threading.Tasks;
using System.Threading;
namespace DailyDigestWorker.Services
{
public interface IWeatherService
{
Task<WeatherData?> GetWeatherAsync(CancellationToken cancellationToken);
}
}

View File

@ -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<OpenWeatherMapService> _logger;
private readonly DataSourceUrls _dataSourceUrls;
private readonly ApiKeys _apiKeys;
// Внедряем зависимости, включая ApiKeys
public OpenWeatherMapService(
IHttpClientFactory httpClientFactory,
IOptions<DataSourceUrls> dataSourceUrlsOptions,
IOptions<ApiKeys> apiKeysOptions, // Добавляем ApiKeys
ILogger<OpenWeatherMapService> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
_dataSourceUrls = dataSourceUrlsOptions.Value;
_apiKeys = apiKeysOptions.Value; // Сохраняем ApiKeys
}
public async Task<WeatherData?> 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<WeatherResponseDto>(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;
}
}
}
}

View File

@ -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<TelegramBotSender> _logger;
private readonly ITelegramBotClient _botClient;
private readonly BotConfiguration _botConfig;
private readonly long? _targetChatId; // Храним распарсенный ID
// Внедряем зависимости
public TelegramBotSender(
ILogger<TelegramBotSender> logger,
ITelegramBotClient botClient, // Внедряем клиент бота
IOptions<BotConfiguration> 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<bool> 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<bool> SendErrorMessageAsync(string errorMessage, CancellationToken cancellationToken)
{
// Используем тот же метод отправки, но без специфичного ParseMode (или можно указать None/Default)
// Можно добавить префикс к сообщению об ошибке
string fullMessage = $"⚠️ **Ошибка при создании дайджеста:**\n{errorMessage}";
return await SendMessageAsync(fullMessage, ParseMode.Markdown, cancellationToken); // Используем Markdown для выделения
}
}
}

View File

@ -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<TelegramChannelReader> _logger;
private readonly Client _client; // Наш Singleton WTelegramClient
private readonly TelegramClientSettings _settings;
private InputPeer? _targetPeer; // Кэшированный InputPeer канала
// Внедряем зависимости
public TelegramChannelReader(
ILogger<TelegramChannelReader> logger,
Client client, // Внедряем WTelegramClient
IOptions<TelegramClientSettings> settingsOptions)
{
_logger = logger;
_client = client;
_settings = settingsOptions.Value;
}
public async Task<List<string>?> 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<string>();
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;
}
}
}
}

267
Worker.cs Normal file
View File

@ -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<Worker> _logger;
// Храним список времен запуска как TimeSpan
private readonly List<TimeSpan> _runTimes;
private readonly IServiceProvider _serviceProvider;
// Внедряем зависимости через конструктор
public Worker(ILogger<Worker> logger,
IOptions<SchedulingSettings> schedulingSettings,
IServiceProvider serviceProvider)
{
_logger = logger;
_serviceProvider = serviceProvider;
_runTimes = new List<TimeSpan>();
// Проверяем наличие 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<ICurrencyService>();
var cryptoService = scope.ServiceProvider.GetRequiredService<ICryptoService>();
var weatherService = scope.ServiceProvider.GetRequiredService<IWeatherService>();
var digestBuilder = scope.ServiceProvider.GetRequiredService<IDigestBuilderService>();
var telegramSender = scope.ServiceProvider.GetRequiredService<ITelegramBotSender>();
var newsReaderService = scope.ServiceProvider.GetRequiredService<ITelegramChannelReader>();
var summarizationService = scope.ServiceProvider.GetRequiredService<ISummarizationService>();
// Константы для чтения новостей (можно вынести в конфиг)
const int NewsMaxAgeHours = 24;
const int NewsFetchLimit = 50; // Сколько последних сообщений проверить
string? newsSummary = null; // Инициализируем переменную для саммари
try
{
// --- 1. Сбор данных ---
_logger.LogInformation("Начало сбора данных для дайджеста...");
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<TimeSpan> runTimes)
{
var now = DateTime.Now;
var today = now.Date;
TimeSpan currentTimeOfDay = now.TimeOfDay;
// Ищем ближайшее время запуска СЕГОДНЯ, которое еще не прошло (или равно текущему времени с точностью до секунды)
// Добавляем небольшую дельту (например, 1 секунду) к currentTimeOfDay, чтобы не пропустить запуск,
// который должен был произойти точно в текущую секунду.
TimeSpan? nextRunTimeToday = runTimes
.Where(t => t >= currentTimeOfDay)
.Cast<TimeSpan?>()
.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);
}
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

45
appsettings.json Normal file
View File

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

BIN
telegram_session.dat Normal file

Binary file not shown.