Добавьте файлы проекта.
This commit is contained in:
parent
b1d3ffa51d
commit
dc6c96f1fc
8
Configuration/ApiKeys.cs
Normal file
8
Configuration/ApiKeys.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace DailyDigestWorker.Configuration
|
||||
{
|
||||
public class ApiKeys
|
||||
{
|
||||
public required string OpenWeatherMap { get; set; }
|
||||
public required string Gemini { get; set; }
|
||||
}
|
||||
}
|
8
Configuration/BotConfiguration.cs
Normal file
8
Configuration/BotConfiguration.cs
Normal 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; }
|
||||
}
|
||||
}
|
9
Configuration/DataSourceUrls.cs
Normal file
9
Configuration/DataSourceUrls.cs
Normal 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; }
|
||||
}
|
||||
}
|
13
Configuration/GeminiSettings.cs
Normal file
13
Configuration/GeminiSettings.cs
Normal 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; }
|
||||
}
|
||||
}
|
8
Configuration/SchedulingSettings.cs
Normal file
8
Configuration/SchedulingSettings.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace DailyDigestWorker.Configuration
|
||||
{
|
||||
public class SchedulingSettings
|
||||
{
|
||||
// Убираем значение по умолчанию. Список будет null, если не задан в конфиге.
|
||||
public List<string>? RunAtTimesLocal { get; set; }
|
||||
}
|
||||
}
|
11
Configuration/TelegramClientSettings.cs
Normal file
11
Configuration/TelegramClientSettings.cs
Normal 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
19
DailyDigestWorker.csproj
Normal 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
25
DailyDigestWorker.sln
Normal 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
12
Models/CryptoData.cs
Normal 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
10
Models/CurrencyData.cs
Normal 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; } // Дата, на которую установлены курсы
|
||||
}
|
||||
}
|
49
Models/Gemini/GeminiRequestDto.cs
Normal file
49
Models/Gemini/GeminiRequestDto.cs
Normal 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"
|
||||
}
|
||||
}
|
49
Models/Gemini/GeminiResponseDto.cs
Normal file
49
Models/Gemini/GeminiResponseDto.cs
Normal 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; }
|
||||
}
|
||||
}
|
37
Models/OpenWeatherMapDtos.cs
Normal file
37
Models/OpenWeatherMapDtos.cs
Normal 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
31
Models/WeatherData.cs
Normal 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
101
Program.cs
Normal 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();
|
12
Properties/launchSettings.json
Normal file
12
Properties/launchSettings.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"DailyDigestWorker": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
132
Services/CbrCurrencyService.cs
Normal file
132
Services/CbrCurrencyService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
97
Services/CoinGeckoCryptoService.cs
Normal file
97
Services/CoinGeckoCryptoService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
113
Services/DigestBuilderService.cs
Normal file
113
Services/DigestBuilderService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
164
Services/GoogleGeminiSummarizationService.cs
Normal file
164
Services/GoogleGeminiSummarizationService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
11
Services/ICryptoService.cs
Normal file
11
Services/ICryptoService.cs
Normal 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);
|
||||
}
|
||||
}
|
12
Services/ICurrencyService.cs
Normal file
12
Services/ICurrencyService.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using DailyDigestWorker.Models;
|
||||
|
||||
namespace DailyDigestWorker.Services
|
||||
{
|
||||
// Интерфейс для сервиса получения курсов валют
|
||||
public interface ICurrencyService
|
||||
{
|
||||
// Асинхронный метод для получения данных
|
||||
// Принимает CancellationToken на случай, если операцию нужно будет прервать
|
||||
Task<CurrencyData?> GetCurrencyRatesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
23
Services/IDigestBuilderService.cs
Normal file
23
Services/IDigestBuilderService.cs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
16
Services/ISummarizationService.cs
Normal file
16
Services/ISummarizationService.cs
Normal 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);
|
||||
}
|
||||
}
|
27
Services/ITelegramBotSender.cs
Normal file
27
Services/ITelegramBotSender.cs
Normal 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);
|
||||
}
|
||||
}
|
19
Services/ITelegramChannelReader.cs
Normal file
19
Services/ITelegramChannelReader.cs
Normal 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);
|
||||
}
|
||||
}
|
11
Services/IWeatherService.cs
Normal file
11
Services/IWeatherService.cs
Normal 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);
|
||||
}
|
||||
}
|
108
Services/OpenWeatherMapService.cs
Normal file
108
Services/OpenWeatherMapService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
84
Services/TelegramBotSender.cs
Normal file
84
Services/TelegramBotSender.cs
Normal 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 для выделения
|
||||
}
|
||||
}
|
||||
}
|
132
Services/TelegramChannelReader.cs
Normal file
132
Services/TelegramChannelReader.cs
Normal 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
267
Worker.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
8
appsettings.Development.json
Normal file
8
appsettings.Development.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
45
appsettings.json
Normal file
45
appsettings.json
Normal 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
BIN
telegram_session.dat
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user