This commit is contained in:
Leonid Pershin
2025-10-16 07:57:22 +03:00
parent b4f8df6816
commit bf1d1c0770
17 changed files with 3 additions and 1320 deletions

View File

@@ -17,8 +17,6 @@
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageReference Include="FluentValidation" Version="11.10.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.10.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -1,12 +0,0 @@
namespace ChatBot.Common.Constants
{
/// <summary>
/// Constants for retry logic
/// </summary>
public static class RetryConstants
{
public const int DefaultMaxRetries = 3;
public const int DefaultBaseDelaySeconds = 1;
public const int DefaultMaxJitterMs = 2000;
}
}

View File

@@ -1,39 +0,0 @@
namespace ChatBot.Common.Results
{
/// <summary>
/// Represents the result of an operation that can succeed or fail
/// </summary>
public class Result
{
public bool IsSuccess { get; }
public string Error { get; }
protected Result(bool isSuccess, string error)
{
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, string.Empty);
public static Result Failure(string error) => new(false, error);
}
/// <summary>
/// Represents the result of an operation that returns a value
/// </summary>
public class Result<T> : Result
{
public T? Value { get; }
private Result(T? value, bool isSuccess, string error)
: base(isSuccess, error)
{
Value = value;
}
public static Result<T> Success(T value) => new(value, true, string.Empty);
public static new Result<T> Failure(string error) => new(default, false, error);
}
}

View File

@@ -1,23 +0,0 @@
namespace ChatBot.Models.Configuration
{
/// <summary>
/// Основные настройки приложения
/// </summary>
public class AppSettings
{
/// <summary>
/// Настройки Telegram бота
/// </summary>
public TelegramBotSettings TelegramBot { get; set; } = new();
/// <summary>
/// Настройки Ollama API
/// </summary>
public OllamaSettings Ollama { get; set; } = new();
/// <summary>
/// Настройки логирования Serilog
/// </summary>
public SerilogSettings Serilog { get; set; } = new();
}
}

View File

@@ -1,60 +0,0 @@
namespace ChatBot.Models.Configuration
{
/// <summary>
/// Настройки логирования Serilog
/// </summary>
public class SerilogSettings
{
/// <summary>
/// Список используемых sink'ов для логирования
/// </summary>
public List<string> Using { get; set; } = new();
/// <summary>
/// Настройки минимального уровня логирования
/// </summary>
public MinimumLevelSettings MinimumLevel { get; set; } = new();
/// <summary>
/// Настройки получателей логов (куда писать логи)
/// </summary>
public List<WriteToSettings> WriteTo { get; set; } = new();
/// <summary>
/// Список обогатителей логов (дополнительная информация)
/// </summary>
public List<string> Enrich { get; set; } = new();
}
/// <summary>
/// Настройки минимального уровня логирования
/// </summary>
public class MinimumLevelSettings
{
/// <summary>
/// Уровень логирования по умолчанию
/// </summary>
public string Default { get; set; } = "Information";
/// <summary>
/// Переопределения уровня логирования для конкретных пространств имен
/// </summary>
public Dictionary<string, string> Override { get; set; } = new();
}
/// <summary>
/// Настройки получателя логов
/// </summary>
public class WriteToSettings
{
/// <summary>
/// Название sink'а для записи логов
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Аргументы для настройки sink'а
/// </summary>
public Dictionary<string, object> Args { get; set; } = new();
}
}

View File

@@ -1,129 +0,0 @@
namespace ChatBot.Models.Configuration.Validators
{
/// <summary>
/// Валидатор конфигурации приложения
/// </summary>
public static class ConfigurationValidator
{
/// <summary>
/// Валидирует все настройки приложения
/// </summary>
/// <param name="settings">Настройки приложения</param>
/// <returns>Результат валидации</returns>
public static ValidationResult ValidateAppSettings(AppSettings settings)
{
var errors = new List<string>();
// Валидация настроек Telegram бота
var telegramResult = ValidateTelegramBotSettings(settings.TelegramBot);
errors.AddRange(telegramResult.Errors);
// Валидация настроек Ollama
var ollamaResult = ValidateOllamaSettings(settings.Ollama);
errors.AddRange(ollamaResult.Errors);
return new ValidationResult { IsValid = !errors.Any(), Errors = errors };
}
/// <summary>
/// Валидирует настройки Telegram бота
/// </summary>
/// <param name="settings">Настройки Telegram бота</param>
/// <returns>Результат валидации</returns>
public static ValidationResult ValidateTelegramBotSettings(TelegramBotSettings settings)
{
var errors = new List<string>();
// Проверка наличия токена бота
if (string.IsNullOrWhiteSpace(settings.BotToken))
{
errors.Add("TelegramBot:BotToken is required");
}
// Проверка формата токена (должен содержать ':' или начинаться с 'bot')
else if (
!settings.BotToken.StartsWith("bot", StringComparison.OrdinalIgnoreCase)
&& !settings.BotToken.Contains(":")
)
{
errors.Add(
"TelegramBot:BotToken appears to be invalid (should contain ':' or start with 'bot')"
);
}
return new ValidationResult { IsValid = !errors.Any(), Errors = errors };
}
/// <summary>
/// Валидирует настройки Ollama
/// </summary>
/// <param name="settings">Настройки Ollama</param>
/// <returns>Результат валидации</returns>
public static ValidationResult ValidateOllamaSettings(OllamaSettings settings)
{
var errors = new List<string>();
// Валидация основных компонентов настроек Ollama
ValidateUrl(settings.Url, errors);
ValidateNumericSettings(settings, errors);
if (string.IsNullOrWhiteSpace(settings.DefaultModel))
{
errors.Add("Ollama:DefaultModel is required");
}
return new ValidationResult { IsValid = !errors.Any(), Errors = errors };
}
/// <summary>
/// Валидирует URL Ollama
/// </summary>
/// <param name="url">URL для проверки</param>
/// <param name="errors">Список ошибок валидации</param>
private static void ValidateUrl(string url, List<string> errors)
{
// Проверка наличия URL
if (string.IsNullOrWhiteSpace(url))
{
errors.Add("Ollama:Url is required");
}
// Проверка корректности URL (должен быть валидным HTTP/HTTPS URL)
else if (
!Uri.TryCreate(url, UriKind.Absolute, out var uri)
|| (uri.Scheme != "http" && uri.Scheme != "https")
)
{
errors.Add("Ollama:Url must be a valid HTTP/HTTPS URL");
}
}
/// <summary>
/// Валидирует числовые параметры настроек Ollama
/// </summary>
/// <param name="settings">Настройки Ollama</param>
/// <param name="errors">Список ошибок валидации</param>
private static void ValidateNumericSettings(OllamaSettings settings, List<string> errors)
{
// Проверка количества повторных попыток (1-10)
if (settings.MaxRetries < 1 || settings.MaxRetries > 10)
{
errors.Add("Ollama:MaxRetries must be between 1 and 10");
}
}
}
/// <summary>
/// Результат валидации конфигурации
/// </summary>
public class ValidationResult
{
/// <summary>
/// Указывает, прошла ли валидация успешно
/// </summary>
public bool IsValid { get; set; }
/// <summary>
/// Список ошибок валидации
/// </summary>
public List<string> Errors { get; set; } = new();
}
}

View File

@@ -1,33 +0,0 @@
using ChatBot.Common.Constants;
using ChatBot.Models.Dto;
using FluentValidation;
namespace ChatBot.Models.Validation
{
/// <summary>
/// Validator for ChatMessage
/// </summary>
public class ChatMessageValidator : AbstractValidator<ChatMessage>
{
public ChatMessageValidator()
{
RuleFor(x => x.Content)
.NotEmpty()
.WithMessage("Message content cannot be empty")
.MaximumLength(10000)
.WithMessage("Message content is too long (max 10000 characters)");
RuleFor(x => x.Role)
.NotEmpty()
.WithMessage("Message role cannot be empty")
.Must(role =>
role == ChatRoles.System
|| role == ChatRoles.User
|| role == ChatRoles.Assistant
)
.WithMessage(
$"Invalid message role. Must be one of: {ChatRoles.System}, {ChatRoles.User}, {ChatRoles.Assistant}"
);
}
}
}

View File

@@ -1,14 +1,11 @@
using ChatBot.Models.Configuration;
using ChatBot.Models.Configuration.Validators;
using ChatBot.Models.Validation;
using ChatBot.Services;
using ChatBot.Services.ErrorHandlers;
using ChatBot.Services.HealthChecks;
using ChatBot.Services.Interfaces;
using ChatBot.Services.Telegram.Commands;
using ChatBot.Services.Telegram.Interfaces;
using ChatBot.Services.Telegram.Services;
using FluentValidation;
using Microsoft.Extensions.Options;
using Serilog;
using Telegram.Bot;
@@ -26,8 +23,6 @@ try
builder.Services.AddSerilog();
// Конфигурируем настройки с валидацией
builder.Services.Configure<AppSettings>(builder.Configuration);
builder
.Services.Configure<TelegramBotSettings>(builder.Configuration.GetSection("TelegramBot"))
.AddSingleton<IValidateOptions<TelegramBotSettings>, TelegramBotSettingsValidator>();
@@ -36,36 +31,10 @@ try
.Services.Configure<OllamaSettings>(builder.Configuration.GetSection("Ollama"))
.AddSingleton<IValidateOptions<OllamaSettings>, OllamaSettingsValidator>();
builder.Services.Configure<SerilogSettings>(builder.Configuration.GetSection("Serilog"));
// Валидируем конфигурацию при старте
builder.Services.AddOptions<TelegramBotSettings>().ValidateOnStart();
builder.Services.AddOptions<OllamaSettings>().ValidateOnStart();
// Валидируем конфигурацию (старый способ для совместимости)
var appSettings = builder.Configuration.Get<AppSettings>();
if (appSettings == null)
{
Log.ForContext<Program>().Fatal("Failed to load configuration");
return;
}
var validationResult = ConfigurationValidator.ValidateAppSettings(appSettings);
if (!validationResult.IsValid)
{
Log.ForContext<Program>().Fatal("Configuration validation failed:");
foreach (var error in validationResult.Errors)
{
Log.ForContext<Program>().Fatal(" - {Error}", error);
}
return;
}
Log.ForContext<Program>().Debug("Configuration validation passed");
// Регистрируем FluentValidation валидаторы
builder.Services.AddValidatorsFromAssemblyContaining<ChatMessageValidator>();
// Регистрируем IOllamaClient
builder.Services.AddSingleton<IOllamaClient>(sp =>
{
@@ -76,13 +45,6 @@ try
// Регистрируем интерфейсы и сервисы
builder.Services.AddSingleton<ISessionStorage, InMemorySessionStorage>();
// Регистрируем error handlers
builder.Services.AddSingleton<IErrorHandler, RateLimitErrorHandler>();
builder.Services.AddSingleton<IErrorHandler, NetworkErrorHandler>();
// Регистрируем retry policy (использует error handlers)
builder.Services.AddSingleton<IRetryPolicy, ExponentialBackoffRetryPolicy>();
// Регистрируем основные сервисы
builder.Services.AddSingleton<ModelService>();
builder.Services.AddSingleton<IAIService, AIService>();

View File

@@ -1,49 +0,0 @@
using ChatBot.Services.Interfaces;
namespace ChatBot.Services.ErrorHandlers
{
/// <summary>
/// Error handler for network-related errors
/// </summary>
public class NetworkErrorHandler : IErrorHandler
{
private readonly ILogger<NetworkErrorHandler> _logger;
public NetworkErrorHandler(ILogger<NetworkErrorHandler> logger)
{
_logger = logger;
}
public bool CanHandle(Exception exception)
{
return exception is HttpRequestException
|| exception is TaskCanceledException
|| exception.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase)
|| exception.Message.Contains("connection", StringComparison.OrdinalIgnoreCase);
}
public async Task<ErrorHandlingResult> HandleAsync(
Exception exception,
int attempt,
string currentModel,
CancellationToken cancellationToken = default
)
{
_logger.LogWarning(
exception,
"Network error on attempt {Attempt} for model {Model}",
attempt,
currentModel
);
// Apply exponential backoff for network errors
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
_logger.LogInformation("Waiting {Delay} before retry due to network error", delay);
await Task.Delay(delay, cancellationToken);
return ErrorHandlingResult.Retry();
}
}
}

View File

@@ -1,52 +0,0 @@
using ChatBot.Services.Interfaces;
namespace ChatBot.Services.ErrorHandlers
{
/// <summary>
/// Error handler for rate limit errors (HTTP 429)
/// </summary>
public class RateLimitErrorHandler : IErrorHandler
{
private readonly ILogger<RateLimitErrorHandler> _logger;
public RateLimitErrorHandler(ILogger<RateLimitErrorHandler> logger)
{
_logger = logger;
}
public bool CanHandle(Exception exception)
{
return exception.Message.Contains("429")
|| exception.Message.Contains("Too Many Requests")
|| exception.Message.Contains("rate limit", StringComparison.OrdinalIgnoreCase);
}
public async Task<ErrorHandlingResult> HandleAsync(
Exception exception,
int attempt,
string currentModel,
CancellationToken cancellationToken = default
)
{
_logger.LogWarning(
exception,
"Rate limit exceeded on attempt {Attempt} for model {Model}",
attempt,
currentModel
);
// Apply exponential backoff for rate limiting
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 2000));
_logger.LogInformation(
"Rate limit hit, waiting {Delay} before retry",
delay.Add(jitter)
);
await Task.Delay(delay.Add(jitter), cancellationToken);
return ErrorHandlingResult.Retry();
}
}
}

View File

@@ -1,111 +0,0 @@
using ChatBot.Common.Constants;
using ChatBot.Models.Configuration;
using ChatBot.Services.Interfaces;
using Microsoft.Extensions.Options;
namespace ChatBot.Services
{
/// <summary>
/// Retry policy with exponential backoff and jitter
/// </summary>
public class ExponentialBackoffRetryPolicy : IRetryPolicy
{
private readonly int _maxRetries;
private readonly ILogger<ExponentialBackoffRetryPolicy> _logger;
private readonly IEnumerable<IErrorHandler> _errorHandlers;
public ExponentialBackoffRetryPolicy(
IOptions<OllamaSettings> settings,
ILogger<ExponentialBackoffRetryPolicy> logger,
IEnumerable<IErrorHandler> errorHandlers
)
{
_maxRetries = settings.Value.MaxRetries;
_logger = logger;
_errorHandlers = errorHandlers;
}
public async Task<T> ExecuteAsync<T>(
Func<Task<T>> action,
CancellationToken cancellationToken = default
)
{
Exception? lastException = null;
for (int attempt = 1; attempt <= _maxRetries; attempt++)
{
try
{
return await action();
}
catch (Exception ex) when (attempt < _maxRetries)
{
lastException = ex;
LogAttemptFailure(ex, attempt);
if (!await HandleErrorAndDecideRetry(ex, attempt, cancellationToken))
break;
}
catch (Exception ex)
{
lastException = ex;
_logger.LogError(ex, "All {MaxRetries} attempts failed", _maxRetries);
}
}
throw new InvalidOperationException(
$"Failed after {_maxRetries} attempts",
lastException
);
}
private void LogAttemptFailure(Exception ex, int attempt)
{
_logger.LogWarning(ex, "Attempt {Attempt}/{MaxRetries} failed", attempt, _maxRetries);
}
private async Task<bool> HandleErrorAndDecideRetry(
Exception ex,
int attempt,
CancellationToken cancellationToken
)
{
var handler = _errorHandlers.FirstOrDefault(h => h.CanHandle(ex));
if (handler == null)
{
await DelayWithBackoff(attempt, cancellationToken);
return true;
}
var result = await handler.HandleAsync(ex, attempt, string.Empty, cancellationToken);
if (result.IsFatal)
{
_logger.LogError("Fatal error occurred: {ErrorMessage}", result.ErrorMessage);
return false;
}
return result.ShouldRetry;
}
private async Task DelayWithBackoff(int attempt, CancellationToken cancellationToken)
{
var baseDelay = TimeSpan.FromSeconds(
Math.Pow(2, attempt - 1) * RetryConstants.DefaultBaseDelaySeconds
);
var jitter = TimeSpan.FromMilliseconds(
Random.Shared.Next(0, RetryConstants.DefaultMaxJitterMs)
);
var delay = baseDelay.Add(jitter);
_logger.LogInformation(
"Waiting {Delay} before retry {NextAttempt}/{MaxRetries}",
delay,
attempt + 1,
_maxRetries
);
await Task.Delay(delay, cancellationToken);
}
}
}

View File

@@ -1,6 +1,6 @@
using System.Collections.Concurrent;
using ChatBot.Models;
using ChatBot.Services.Interfaces;
using System.Collections.Concurrent;
namespace ChatBot.Services
{

View File

@@ -1,42 +0,0 @@
namespace ChatBot.Services.Interfaces
{
/// <summary>
/// Interface for error handling strategy
/// </summary>
public interface IErrorHandler
{
/// <summary>
/// Check if this handler can handle the exception
/// </summary>
bool CanHandle(Exception exception);
/// <summary>
/// Handle the exception and return result
/// </summary>
Task<ErrorHandlingResult> HandleAsync(
Exception exception,
int attempt,
string currentModel,
CancellationToken cancellationToken = default
);
}
/// <summary>
/// Result of error handling
/// </summary>
public class ErrorHandlingResult
{
public bool ShouldRetry { get; set; }
public string? NewModel { get; set; }
public bool IsFatal { get; set; }
public string? ErrorMessage { get; set; }
public static ErrorHandlingResult Retry(string? newModel = null) =>
new() { ShouldRetry = true, NewModel = newModel };
public static ErrorHandlingResult Fatal(string errorMessage) =>
new() { IsFatal = true, ErrorMessage = errorMessage };
public static ErrorHandlingResult NoRetry() => new() { ShouldRetry = false };
}
}

View File

@@ -1,16 +0,0 @@
namespace ChatBot.Services.Interfaces
{
/// <summary>
/// Interface for retry policy
/// </summary>
public interface IRetryPolicy
{
/// <summary>
/// Execute an action with retry logic
/// </summary>
Task<T> ExecuteAsync<T>(
Func<Task<T>> action,
CancellationToken cancellationToken = default
);
}
}

View File

@@ -15,24 +15,20 @@ namespace ChatBot.Services.Telegram.Services
{
private readonly ILogger<TelegramBotService> _logger;
private readonly ITelegramBotClient _botClient;
private readonly TelegramBotSettings _telegramBotSettings;
private readonly ITelegramMessageHandler _messageHandler;
private readonly ITelegramErrorHandler _errorHandler;
public TelegramBotService(
ILogger<TelegramBotService> logger,
IOptions<TelegramBotSettings> telegramBotSettings,
ITelegramBotClient botClient,
ITelegramMessageHandler messageHandler,
ITelegramErrorHandler errorHandler
)
{
_logger = logger;
_telegramBotSettings = telegramBotSettings.Value;
_botClient = botClient;
_messageHandler = messageHandler;
_errorHandler = errorHandler;
ValidateConfiguration();
_botClient = new TelegramBotClient(_telegramBotSettings.BotToken);
}
/// <summary>
@@ -98,15 +94,5 @@ namespace ChatBot.Services.Telegram.Services
await Task.Delay(1000, stoppingToken);
}
}
private void ValidateConfiguration()
{
if (string.IsNullOrEmpty(_telegramBotSettings.BotToken))
{
throw new InvalidOperationException(
"Bot token is not configured. Please set TelegramBot:BotToken in appsettings.json"
);
}
}
}
}