diff --git a/.editorconfig b/.editorconfig
new file mode 100755
index 0000000..68d26a1
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,193 @@
+[*.cs]
+
+# SA1101: Prefix local calls with this
+dotnet_diagnostic.SA1101.severity = none
+[*.cs]
+#### Стили именования ####
+
+# Правила именования
+
+dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
+dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
+dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
+
+dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.types_should_be_pascal_case.symbols = types
+dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
+dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
+
+# Спецификации символов
+
+dotnet_naming_symbols.interface.applicable_kinds = interface
+dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.interface.required_modifiers =
+
+dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
+dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.types.required_modifiers =
+
+dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
+dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.non_field_members.required_modifiers =
+
+# Стили именования
+
+dotnet_naming_style.begins_with_i.required_prefix = I
+dotnet_naming_style.begins_with_i.required_suffix =
+dotnet_naming_style.begins_with_i.word_separator =
+dotnet_naming_style.begins_with_i.capitalization = pascal_case
+
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.capitalization = pascal_case
+
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.capitalization = pascal_case
+csharp_indent_labels = one_less_than_current
+csharp_style_throw_expression = true:suggestion
+csharp_style_prefer_null_check_over_type_check = true:suggestion
+csharp_prefer_simple_default_expression = true:suggestion
+csharp_style_prefer_local_over_anonymous_function = true:suggestion
+csharp_style_prefer_index_operator = true:suggestion
+csharp_style_prefer_range_operator = true:suggestion
+csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
+csharp_style_prefer_implicitly_typed_lambda_expression = true:suggestion
+csharp_style_prefer_tuple_swap = true:suggestion
+csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion
+csharp_style_prefer_utf8_string_literals = true:suggestion
+csharp_style_inlined_variable_declaration = true:suggestion
+csharp_space_around_binary_operators = before_and_after
+dotnet_diagnostic.SA1200.severity = none
+
+# CA1848: Использовать делегаты LoggerMessage
+dotnet_diagnostic.CA1848.severity = none
+dotnet_diagnostic.SA1010.severity = none
+csharp_style_deconstructed_variable_declaration = true:suggestion
+csharp_style_unused_value_assignment_preference = discard_variable:suggestion
+csharp_style_unused_value_expression_statement_preference = discard_variable:silent
+csharp_style_conditional_delegate_call = true:suggestion
+csharp_style_prefer_pattern_matching = true:silent
+csharp_style_prefer_switch_expression = true:suggestion
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_prefer_not_pattern = true:suggestion
+csharp_style_prefer_extended_property_pattern = true:suggestion
+csharp_style_var_for_built_in_types = false:silent
+csharp_style_var_elsewhere = false:silent
+csharp_style_var_when_type_is_apparent = false:silent
+csharp_prefer_simple_using_statement = true:suggestion
+csharp_prefer_braces = true:silent
+csharp_style_namespace_declarations = block_scoped:silent
+csharp_style_prefer_method_group_conversion = true:silent
+csharp_style_prefer_top_level_statements = true:silent
+csharp_style_prefer_primary_constructors = true:suggestion
+csharp_prefer_system_threading_lock = true:suggestion
+csharp_using_directive_placement = outside_namespace:silent
+csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent
+csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent
+csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent
+csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
+csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
+csharp_prefer_static_local_function = true:suggestion
+csharp_prefer_static_anonymous_function = true:suggestion
+csharp_style_prefer_readonly_struct = true:suggestion
+csharp_style_prefer_readonly_struct_member = true:suggestion
+csharp_style_expression_bodied_methods = false:silent
+csharp_style_expression_bodied_constructors = false:silent
+csharp_style_expression_bodied_operators = false:silent
+csharp_style_expression_bodied_properties = true:silent
+csharp_style_expression_bodied_indexers = true:silent
+csharp_style_expression_bodied_accessors = true:silent
+csharp_style_expression_bodied_lambdas = true:silent
+csharp_style_expression_bodied_local_functions = false:silent
+
+[*.vb]
+#### Стили именования ####
+
+# Правила именования
+
+dotnet_naming_rule.interface_should_be_начинается_с_i.severity = suggestion
+dotnet_naming_rule.interface_should_be_начинается_с_i.symbols = interface
+dotnet_naming_rule.interface_should_be_начинается_с_i.style = начинается_с_i
+
+dotnet_naming_rule.типы_should_be_всечастиспрописнойбуквы.severity = suggestion
+dotnet_naming_rule.типы_should_be_всечастиспрописнойбуквы.symbols = типы
+dotnet_naming_rule.типы_should_be_всечастиспрописнойбуквы.style = всечастиспрописнойбуквы
+
+dotnet_naming_rule.не_являющиеся_полем_члены_should_be_всечастиспрописнойбуквы.severity = suggestion
+dotnet_naming_rule.не_являющиеся_полем_члены_should_be_всечастиспрописнойбуквы.symbols = не_являющиеся_полем_члены
+dotnet_naming_rule.не_являющиеся_полем_члены_should_be_всечастиспрописнойбуквы.style = всечастиспрописнойбуквы
+
+# Спецификации символов
+
+dotnet_naming_symbols.interface.applicable_kinds = interface
+dotnet_naming_symbols.interface.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected
+dotnet_naming_symbols.interface.required_modifiers =
+
+dotnet_naming_symbols.типы.applicable_kinds = class, struct, interface, enum
+dotnet_naming_symbols.типы.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected
+dotnet_naming_symbols.типы.required_modifiers =
+
+dotnet_naming_symbols.не_являющиеся_полем_члены.applicable_kinds = property, event, method
+dotnet_naming_symbols.не_являющиеся_полем_члены.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected
+dotnet_naming_symbols.не_являющиеся_полем_члены.required_modifiers =
+
+# Стили именования
+
+dotnet_naming_style.начинается_с_i.required_prefix = I
+dotnet_naming_style.начинается_с_i.required_suffix =
+dotnet_naming_style.начинается_с_i.word_separator =
+dotnet_naming_style.начинается_с_i.capitalization = pascal_case
+
+dotnet_naming_style.всечастиспрописнойбуквы.required_prefix =
+dotnet_naming_style.всечастиспрописнойбуквы.required_suffix =
+dotnet_naming_style.всечастиспрописнойбуквы.word_separator =
+dotnet_naming_style.всечастиспрописнойбуквы.capitalization = pascal_case
+
+dotnet_naming_style.всечастиспрописнойбуквы.required_prefix =
+dotnet_naming_style.всечастиспрописнойбуквы.required_suffix =
+dotnet_naming_style.всечастиспрописнойбуквы.word_separator =
+dotnet_naming_style.всечастиспрописнойбуквы.capitalization = pascal_case
+
+[*.{cs,vb}]
+dotnet_style_operator_placement_when_wrapping = beginning_of_line
+tab_width = 4
+indent_size = 4
+dotnet_style_coalesce_expression = true:suggestion
+dotnet_style_null_propagation = true:suggestion
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
+dotnet_style_prefer_auto_properties = true:silent
+dotnet_style_object_initializer = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
+dotnet_style_prefer_conditional_expression_over_assignment = true:silent
+dotnet_style_prefer_conditional_expression_over_return = true:silent
+dotnet_style_explicit_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_prefer_compound_assignment = true:suggestion
+dotnet_style_prefer_simplified_interpolation = true:suggestion
+dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
+dotnet_style_namespace_match_folder = true:suggestion
+end_of_line = crlf
+dotnet_code_quality_unused_parameters = all:suggestion
+dotnet_style_predefined_type_for_member_access = true:silent
+dotnet_style_predefined_type_for_locals_parameters_members = true:silent
+dotnet_style_qualification_for_field = false:silent
+dotnet_style_qualification_for_method = false:silent
+dotnet_style_qualification_for_property = false:silent
+dotnet_style_qualification_for_event = false:silent
+dotnet_style_allow_multiple_blank_lines_experimental = true:silent
+dotnet_style_allow_statement_immediately_after_block_experimental = true:silent
+dotnet_style_readonly_field = true:suggestion
+dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 35063fc..3c1bc91 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,4 +51,40 @@ CodeCoverage/
# NUnit
*.VisualState.xml
TestResult.xml
-nunit-*.xml
\ No newline at end of file
+nunit-*.xml
+
+# Секреты
+appsettings.Development.json
+appsettings.Production.json
+.env
+*.env
+secrets.json
+
+# IDE файлы
+.vscode/
+.idea/
+*.swp
+*.swo
+
+# Временные файлы
+bin/
+obj/
+.vs/
+*.suo
+*.user
+
+# Docker
+docker-compose.override.yml
+
+# Логи
+*.log
+logs/
+
+# Бинарные файлы
+*.dll
+*.exe
+*.pdb
+
+# БД
+*.db
+__pycache__/
diff --git a/Bot/Bot.csproj b/Bot/Bot.csproj
new file mode 100755
index 0000000..0962ab4
--- /dev/null
+++ b/Bot/Bot.csproj
@@ -0,0 +1,31 @@
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+ 2579afaa-0ecc-4aa5-97ea-147ad5b1439f
+
+
+
diff --git a/Bot/Hadlers/AdminHandler.cs b/Bot/Hadlers/AdminHandler.cs
new file mode 100755
index 0000000..35fcbc4
--- /dev/null
+++ b/Bot/Hadlers/AdminHandler.cs
@@ -0,0 +1,201 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Bot.Services;
+using Microsoft.Extensions.Logging;
+using Services;
+using Telegram.Bot;
+using Telegram.Bot.Types;
+using static Bot.Services.AdminStateService;
+
+namespace Bot.Handlers;
+
+///
+/// Handles administrative operations for goal management including creation and validation.
+///
+public class AdminHandler
+{
+ private readonly ILogger logger;
+ private readonly IGoalService goalService;
+ private readonly AdminStateService adminStateService;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Logger instance for tracking operations.
+ /// Service for goal-related operations.
+ /// Service for managing admin state.
+ public AdminHandler(
+ ILogger logger,
+ IGoalService goalService,
+ AdminStateService adminStateService)
+ {
+ this.logger = logger;
+ this.goalService = goalService;
+ this.adminStateService = adminStateService;
+
+ this.logger.LogDebug("AdminHandler initialized successfully");
+ }
+
+ ///
+ /// Processes goal creation workflow for administrators.
+ ///
+ /// Telegram bot client instance.
+ /// Incoming message from user.
+ /// Cancellation token for async operations.
+ /// A representing the asynchronous operation.
+ public virtual async Task HandleAdminGoalCreationAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken)
+ {
+ if (message.From?.Id == null)
+ {
+ logger.LogWarning("Received message with null user ID");
+ return;
+ }
+
+ var userId = message.From.Id;
+ var chatId = message.Chat.Id;
+ var state = adminStateService.GetState(userId);
+
+ if (state == null)
+ {
+ logger.LogWarning("No admin state found for user {UserId}", userId);
+ return;
+ }
+
+ if (string.IsNullOrEmpty(message.Text))
+ {
+ logger.LogWarning("Received empty message text from user {UserId}", userId);
+ return;
+ }
+
+ try
+ {
+ switch (state.CurrentStep)
+ {
+ case AdminStateService.AdminGoalStep.WaitingForTitle:
+ await ProcessTitleStepAsync(botClient, userId, chatId, message.Text, cancellationToken);
+ break;
+
+ case AdminStateService.AdminGoalStep.WaitingForDescription:
+ await ProcessDescriptionStepAsync(botClient, userId, chatId, message.Text, cancellationToken);
+ break;
+
+ case AdminStateService.AdminGoalStep.WaitingForAmount:
+ await ProcessAmountStepAsync(botClient, userId, chatId, message.Text, state, cancellationToken);
+ break;
+
+ default:
+ logger.LogWarning("Unknown admin step {CurrentStep} for user {UserId}", state.CurrentStep, userId);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error processing admin goal creation for user {UserId} at step {CurrentStep}", userId, state.CurrentStep);
+ await SendErrorMessageAsync(botClient, chatId, cancellationToken);
+ }
+ }
+
+ ///
+ /// Initiates the goal creation process for administrators.
+ ///
+ /// Telegram bot client instance.
+ /// Chat identifier.
+ /// User identifier.
+ /// Cancellation token for async operations.
+ /// A representing the asynchronous operation.
+ public virtual async Task StartGoalCreationAsync(ITelegramBotClient botClient, long chatId, long userId, CancellationToken cancellationToken)
+ {
+ logger.LogInformation("Starting goal creation process for admin user {UserId}", userId);
+
+ try
+ {
+ adminStateService.StartGoalCreation(userId, chatId);
+ await botClient.SendMessage(chatId, "🎯 Введите название новой цели:", cancellationToken: cancellationToken);
+
+ logger.LogDebug("Goal creation started successfully for user {UserId}", userId);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to start goal creation for user {UserId}", userId);
+ throw;
+ }
+ }
+
+ ///
+ /// Handles unauthorized admin access attempts.
+ ///
+ /// Telegram bot client instance.
+ /// Chat identifier.
+ /// Cancellation token for async operations.
+ /// A representing the asynchronous operation.
+ public virtual async Task HandleNotAdmin(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken)
+ {
+ logger.LogWarning("Non-admin access attempt detected for chat {ChatId}", chatId);
+
+ await botClient.SendMessage(chatId, "❌ Вы не являетесь админом", cancellationToken: cancellationToken);
+ }
+
+ private async Task ProcessTitleStepAsync(ITelegramBotClient botClient, long userId, long chatId, string title, CancellationToken cancellationToken)
+ {
+ if (title.Length >= 255)
+ {
+ logger.LogWarning("User {UserId} provided title that exceeds length limit: {TitleLength}", userId, title.Length);
+
+ adminStateService.CancelGoalCreation(userId);
+ await botClient.SendMessage(chatId, "❌ Слишком длинное название цели \n Попробуйте создать цель заново:", cancellationToken: cancellationToken);
+ return;
+ }
+
+ logger.LogDebug("Setting title for user {UserId}", userId);
+ adminStateService.SetTitle(userId, title);
+ await botClient.SendMessage(chatId, "📝 Введите описание цели:", cancellationToken: cancellationToken);
+ }
+
+ private async Task ProcessDescriptionStepAsync(ITelegramBotClient botClient, long userId, long chatId, string description, CancellationToken cancellationToken)
+ {
+ logger.LogDebug("Setting description for user {UserId}", userId);
+
+ adminStateService.SetDescription(userId, description);
+ await botClient.SendMessage(chatId, "💰 Введите целевую сумму в рублях:", cancellationToken: cancellationToken);
+ }
+
+ private async Task ProcessAmountStepAsync(ITelegramBotClient botClient, long userId, long chatId, string amountText, AdminGoalCreationState state, CancellationToken cancellationToken)
+ {
+ if (!decimal.TryParse(amountText, out decimal amount) || amount <= 0 || amount >= 100000000)
+ {
+ logger.LogWarning("User {UserId} provided invalid amount: {AmountText}", userId, amountText);
+
+ adminStateService.CancelGoalCreation(userId);
+ await botClient.SendMessage(chatId, "❌ Введите корректную сумму до 99999999 \n Попробуйте создать цель заново:", cancellationToken: cancellationToken);
+ return;
+ }
+
+ try
+ {
+ logger.LogInformation("Creating goal for user {UserId} with amount {Amount}", userId, amount);
+
+ var goal = await goalService.CreateGoalAsync(state.Title!, state.Description!, amount);
+ adminStateService.CancelGoalCreation(userId);
+
+ await botClient.SendMessage(
+ chatId: chatId,
+ text: $"✅ Цель создана!\n🎯 {goal.Title}\n💫 Описание: {goal.Description} \n💰 Сумма: {amount}₽",
+ cancellationToken: cancellationToken);
+
+ logger.LogInformation("Goal created successfully with ID {GoalId} for user {UserId}", goal.Id, userId);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to create goal in database for user {UserId}", userId);
+ await SendErrorMessageAsync(botClient, chatId, cancellationToken);
+ }
+ }
+
+ private async Task SendErrorMessageAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken) =>
+ await botClient.SendMessage(
+ chatId,
+ "❌ Произошла ошибка при создании цели. Пожалуйста, попробуйте позже.",
+ cancellationToken: cancellationToken);
+}
\ No newline at end of file
diff --git a/Bot/Hadlers/CallbackQueryHandler.cs b/Bot/Hadlers/CallbackQueryHandler.cs
new file mode 100755
index 0000000..d90d9e3
--- /dev/null
+++ b/Bot/Hadlers/CallbackQueryHandler.cs
@@ -0,0 +1,182 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Bot.Services;
+using Microsoft.Extensions.Logging;
+using Services;
+using Telegram.Bot;
+using Telegram.Bot.Polling;
+using Telegram.Bot.Types;
+
+namespace Bot.Handlers;
+
+///
+/// Handles callback queries from inline keyboards in Telegram bot.
+///
+public class CallbackQueryHandler : IUpdateHandlerCommand
+{
+ private readonly ILogger logger;
+ private readonly PaymentHandler paymentHandler;
+ private readonly CommandHandler commandHandler;
+ private readonly UserStateService userStateService;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Logger instance for tracking operations.
+ /// Handler for payment-related operations.
+ /// Handler for command processing.
+ /// Service for managing user state.
+ public CallbackQueryHandler(
+ ILogger logger,
+ PaymentHandler paymentHandler,
+ CommandHandler commandHandler,
+ UserStateService userStateService)
+ {
+ this.logger = logger;
+ this.paymentHandler = paymentHandler;
+ this.commandHandler = commandHandler;
+ this.userStateService = userStateService;
+
+ this.logger.LogDebug("CallbackQueryHandler initialized successfully");
+ }
+
+ ///
+ /// Determines whether this handler can process the update.
+ ///
+ /// The update to check.
+ /// True if the update contains a callback query; otherwise, false.
+ public bool CanHandle(Update update) => update.CallbackQuery != null;
+
+ ///
+ /// Processes callback query updates from inline keyboards.
+ ///
+ /// Telegram bot client instance.
+ /// The update containing callback query.
+ /// Cancellation token for async operations.
+ /// A representing the asynchronous operation.
+ public async Task HandleAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
+ {
+ var callbackQuery = update.CallbackQuery!;
+ logger.LogInformation("Received inline keyboard callback from user: {UserId}", callbackQuery.From.Id);
+
+ if (callbackQuery.Message?.Chat == null)
+ {
+ logger.LogWarning("Callback query from user {UserId} has no chat information", callbackQuery.From.Id);
+ return;
+ }
+
+ try
+ {
+ var callbackData = callbackQuery.Data;
+ var chatId = callbackQuery.Message.Chat.Id;
+ var userId = callbackQuery.From.Id;
+
+ if (string.IsNullOrEmpty(callbackData))
+ {
+ logger.LogWarning("Empty callback data received from user {UserId}", userId);
+ await botClient.AnswerCallbackQuery(callbackQuery.Id, "❌ Пустая команда", cancellationToken: cancellationToken);
+ return;
+ }
+
+ await ProcessCallbackDataAsync(botClient, callbackQuery, callbackData, chatId, userId, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error handling callback query from user {UserId}", callbackQuery.From.Id);
+ await SafeAnswerCallbackQueryAsync(botClient, callbackQuery.Id, "❌ Произошла ошибка", cancellationToken);
+ }
+ }
+
+ private async Task ProcessCallbackDataAsync(ITelegramBotClient botClient, CallbackQuery callbackQuery, string callbackData, long chatId, long userId, CancellationToken cancellationToken)
+ {
+ switch (callbackData)
+ {
+ case "enter_custom_amount":
+ await HandleCustomAmountAsync(botClient, callbackQuery, chatId, userId, cancellationToken);
+ break;
+
+ case "donate_100":
+ case "donate_500":
+ case "donate_1000":
+ case "donate_5000":
+ await HandlePredefinedDonationAsync(botClient, callbackQuery, callbackData, chatId, userId, cancellationToken);
+ break;
+
+ case "show_stats":
+ await HandleShowStatsAsync(botClient, callbackQuery, chatId, cancellationToken);
+ break;
+
+ default:
+ logger.LogWarning("Unknown callback data: {CallbackData} from user {UserId}", callbackData, userId);
+ await botClient.AnswerCallbackQuery(callbackQuery.Id, $"❌ Неизвестная команда: {callbackData}", cancellationToken: cancellationToken);
+ break;
+ }
+ }
+
+ private async Task HandleCustomAmountAsync(ITelegramBotClient botClient, CallbackQuery callbackQuery, long chatId, long userId, CancellationToken cancellationToken)
+ {
+ logger.LogDebug("User {UserId} selected custom amount donation", userId);
+
+ userStateService.SetWaitingForAmount(userId, chatId);
+ await botClient.AnswerCallbackQuery(callbackQuery.Id, "Введите сумму пожертвования в рублях", cancellationToken: cancellationToken);
+ await botClient.SendMessage(chatId, "💎 Введите сумму пожертвования в рублях:", cancellationToken: cancellationToken);
+
+ logger.LogDebug("Custom amount prompt sent to user {UserId}", userId);
+ }
+
+ private async Task HandlePredefinedDonationAsync(ITelegramBotClient botClient, CallbackQuery callbackQuery, string callbackData, long chatId, long userId, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var amount = int.Parse(callbackData.Split('_')[1]);
+ logger.LogInformation("User {UserId} selected predefined donation amount: {Amount}", userId, amount);
+
+ await paymentHandler.CreateDonationInvoice(botClient, chatId, userId, amount, cancellationToken);
+ await botClient.AnswerCallbackQuery(callbackQuery.Id, cancellationToken: cancellationToken);
+
+ logger.LogDebug("Donation invoice created for user {UserId} with amount {Amount}", userId, amount);
+ }
+ catch (FormatException ex)
+ {
+ logger.LogError(ex, "Failed to parse donation amount from callback data: {CallbackData}", callbackData);
+ await botClient.AnswerCallbackQuery(callbackQuery.Id, "❌ Ошибка обработки суммы", cancellationToken: cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to create donation invoice for user {UserId}", userId);
+ await botClient.AnswerCallbackQuery(callbackQuery.Id, "❌ Ошибка создания платежа", cancellationToken: cancellationToken);
+ }
+ }
+
+ private async Task HandleShowStatsAsync(ITelegramBotClient botClient, CallbackQuery callbackQuery, long chatId, CancellationToken cancellationToken)
+ {
+ logger.LogDebug("User {UserId} requested statistics", callbackQuery.From.Id);
+
+ try
+ {
+ await commandHandler.HandleStatsCommand(botClient, chatId, cancellationToken);
+ await botClient.AnswerCallbackQuery(callbackQuery.Id, cancellationToken: cancellationToken);
+
+ logger.LogDebug("Statistics sent to user {UserId}", callbackQuery.From.Id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to show statistics for user {UserId}", callbackQuery.From.Id);
+ await botClient.AnswerCallbackQuery(callbackQuery.Id, "❌ Ошибка загрузки статистики", cancellationToken: cancellationToken);
+ }
+ }
+
+ private async Task SafeAnswerCallbackQueryAsync(ITelegramBotClient botClient, string callbackQueryId, string message, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await botClient.AnswerCallbackQuery(callbackQueryId, message, cancellationToken: cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to answer callback query {CallbackQueryId}", callbackQueryId);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bot/Hadlers/CommandHandler.cs b/Bot/Hadlers/CommandHandler.cs
new file mode 100755
index 0000000..ca666ca
--- /dev/null
+++ b/Bot/Hadlers/CommandHandler.cs
@@ -0,0 +1,272 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Bot.Services;
+using Data.Models;
+using Microsoft.Extensions.Logging;
+using Services;
+using Telegram.Bot;
+using Telegram.Bot.Types;
+using Telegram.Bot.Types.Enums;
+
+namespace Bot.Handlers;
+
+///
+/// Handles bot commands and routes them to appropriate handlers.
+///
+public class CommandHandler
+{
+ private readonly ILogger logger;
+ private readonly IGoalService goalService;
+ private readonly KeyboardService keyboardService;
+ private readonly AdminHandler adminHandler;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Logger instance for tracking operations.
+ /// Service for goal-related operations.
+ /// Service for keyboard management.
+ /// Handler for administrative operations.
+ public CommandHandler(
+ ILogger logger,
+ IGoalService goalService,
+ KeyboardService keyboardService,
+ AdminHandler adminHandler)
+ {
+ this.logger = logger;
+ this.goalService = goalService;
+ this.keyboardService = keyboardService;
+ this.adminHandler = adminHandler;
+
+ this.logger.LogDebug("CommandHandler initialized successfully");
+ }
+
+ ///
+ /// Processes and routes bot commands to appropriate handlers.
+ ///
+ /// Telegram bot client instance.
+ /// The message containing the command.
+ /// Cancellation token for async operations.
+ /// A representing the asynchronous operation.
+ public virtual async Task HandleCommandAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken)
+ {
+ if (message.From?.Id == null)
+ {
+ logger.LogWarning("Received command from message with null user");
+ return;
+ }
+
+ if (string.IsNullOrEmpty(message.Text))
+ {
+ logger.LogWarning("Received empty message text from user {UserId}", message.From.Id);
+ return;
+ }
+
+ var chatId = message.Chat.Id;
+ var userId = message.From.Id;
+ var messageText = message.Text;
+
+ logger.LogInformation("Processing command: {Command} from user {UserId}", messageText, userId);
+
+ try
+ {
+ switch (messageText)
+ {
+ case "/start":
+ case "🔄 Обновить":
+ case "Обновить":
+ await HandleStartCommandAsync(botClient, message, cancellationToken);
+ break;
+ case "/donate":
+ case "💳 Пожертвовать":
+ case "Пожертвовать":
+ await HandleDonateCommandAsync(botClient, chatId, cancellationToken);
+ break;
+ case "/stats":
+ case "📊 Статистика":
+ case "Статистика":
+ await HandleStatsCommand(botClient, chatId, cancellationToken);
+ break;
+ case "/addgoal":
+ case "📝 Создать новую цель":
+ case "Создать новую цель":
+ await HandleAddGoalCommandAsync(botClient, chatId, userId, cancellationToken);
+ break;
+ default:
+ await HandleUnknownCommandAsync(botClient, message, cancellationToken);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error processing command {Command} from user {UserId}", messageText, userId);
+ await SendErrorMessageAsync(botClient, chatId, cancellationToken);
+ }
+ }
+
+ ///
+ /// Handles statistics command requests.
+ ///
+ /// Telegram bot client instance.
+ /// Chat identifier.
+ /// Cancellation token for async operations.
+ /// A representing the asynchronous operation.
+ public virtual async Task HandleStatsCommand(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken)
+ {
+ logger.LogInformation("Processing stats command for chat {ChatId}", chatId);
+
+ try
+ {
+ var stats = await goalService.GetGoalStatsAsync();
+ await botClient.SendMessage(
+ chatId: chatId,
+ text: stats,
+ parseMode: ParseMode.Markdown,
+ cancellationToken: cancellationToken);
+
+ logger.LogDebug("Statistics sent successfully to chat {ChatId}", chatId);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting stats for chat {ChatId}", chatId);
+ await SendErrorMessageAsync(botClient, chatId, cancellationToken);
+ }
+ }
+
+ private async Task HandleStartCommandAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken)
+ {
+ var chatId = message.Chat.Id;
+ var userId = message.From.Id;
+
+ logger.LogDebug("Processing start command for user {UserId}", userId);
+
+ try
+ {
+ var stats = await goalService.GetStartStats();
+ var conclusion = $"🙏 Добро пожаловать в бота для сбора пожертвований! \n{stats} \n\nВыберите действие:";
+
+ var isAdmin = await goalService.IsUserAdminAsync(userId);
+ logger.LogDebug("User {UserId} admin status: {IsAdmin}", userId, isAdmin);
+
+ var keyboard = isAdmin
+ ? keyboardService.GetMainMenuKeyboardForAdmin()
+ : keyboardService.GetMainMenuKeyboard();
+
+ await botClient.SendMessage(
+ chatId: chatId,
+ text: conclusion,
+ parseMode: ParseMode.Markdown,
+ replyMarkup: keyboard,
+ cancellationToken: cancellationToken);
+
+ logger.LogDebug("Start command processed successfully for user {UserId}", userId);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error processing start command for user {UserId}", userId);
+ await SendErrorMessageAsync(botClient, chatId, cancellationToken);
+ }
+ }
+
+ private async Task HandleDonateCommandAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken)
+ {
+ logger.LogDebug("Processing donate command for chat {ChatId}", chatId);
+
+ try
+ {
+ var goal = await goalService.GetActiveGoalAsync();
+ if (goal == null)
+ {
+ logger.LogWarning("No active goal found for donate command in chat {ChatId}", chatId);
+ await botClient.SendMessage(
+ chatId: chatId,
+ text: "❌ В данный момент нет активных целей для пожертвований.",
+ cancellationToken: cancellationToken);
+ return;
+ }
+
+ var keyboard = keyboardService.GetDonationAmountKeyboard();
+ await botClient.SendMessage(
+ chatId: chatId,
+ text: $"💝 **Пожертвование: {goal.Title}** \n\nВыберите сумму пожертвования или введите свою:",
+ parseMode: ParseMode.Markdown,
+ replyMarkup: keyboard,
+ cancellationToken: cancellationToken);
+
+ logger.LogDebug("Donate command processed successfully for chat {ChatId} with goal {GoalTitle}", chatId, goal.Title);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error processing donate command for chat {ChatId}", chatId);
+ await SendErrorMessageAsync(botClient, chatId, cancellationToken);
+ }
+ }
+
+ private async Task HandleAddGoalCommandAsync(ITelegramBotClient botClient, long chatId, long userId, CancellationToken cancellationToken)
+ {
+ logger.LogDebug("Processing add goal command for user {UserId}", userId);
+
+ try
+ {
+ if (await goalService.IsUserAdminAsync(userId))
+ {
+ logger.LogInformation("Admin user {UserId} starting goal creation", userId);
+ await adminHandler.StartGoalCreationAsync(botClient, chatId, userId, cancellationToken);
+ }
+ else
+ {
+ logger.LogWarning("Non-admin user {UserId} attempted to create goal", userId);
+ await adminHandler.HandleNotAdmin(botClient, chatId, cancellationToken);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error processing add goal command for user {UserId}", userId);
+ await SendErrorMessageAsync(botClient, chatId, cancellationToken);
+ }
+ }
+
+ private async Task HandleUnknownCommandAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken)
+ {
+ var chatId = message.Chat.Id;
+ var userId = message.From.Id;
+
+ logger.LogWarning("Unknown command from user {UserId}: {CommandText}", userId, message.Text);
+
+ try
+ {
+ var isAdmin = await goalService.IsUserAdminAsync(userId);
+ var responseText = isAdmin
+ ? "🤔 Неизвестная команда \n\n Доступные команды: \n• Нажмите 📊 Статистика для просмотра прогресса \n• Нажмите 📝 Создать новую цель"
+ : "🤔 Неизвестная команда \n\n Доступные команды: \n• Нажмите 📊 Статистика для просмотра прогресса \n• Нажмите 💳 Пожертвовать для помощи проекту";
+
+ await botClient.SendMessage(
+ chatId: chatId,
+ text: responseText,
+ cancellationToken: cancellationToken);
+
+ logger.LogDebug("Unknown command response sent to user {UserId}", userId);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error sending unknown command response to user {UserId}", userId);
+ }
+ }
+
+ private async Task SendErrorMessageAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await botClient.SendMessage(
+ chatId: chatId,
+ text: "❌ Не удалось выполнить команду. Попробуйте позже.",
+ cancellationToken: cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to send error message to chat {ChatId}", chatId);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bot/Hadlers/IUpdateHandlerCommand.cs b/Bot/Hadlers/IUpdateHandlerCommand.cs
new file mode 100755
index 0000000..5b9abf8
--- /dev/null
+++ b/Bot/Hadlers/IUpdateHandlerCommand.cs
@@ -0,0 +1,40 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Telegram.Bot;
+using Telegram.Bot.Types;
+
+namespace Bot.Handlers;
+
+///
+/// Defines the contract for Telegram bot update command handlers.
+///
+public interface IUpdateHandlerCommand
+{
+ ///
+ /// Asynchronously processes an incoming update from Telegram Bot.
+ ///
+ /// Telegram Bot client for API interactions.
+ /// The update containing data to process.
+ /// Cancellation token for the asynchronous operation.
+ /// A task representing the asynchronous processing operation.
+ ///
+ /// Implementations should only handle updates for which the method returns true.
+ /// In case of errors, it's recommended to log exceptions and handle them appropriately.
+ ///
+ Task HandleAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken);
+
+ ///
+ /// Determines whether this handler can process the specified update.
+ ///
+ /// The update to check.
+ ///
+ /// true if this handler can process the update; otherwise, false.
+ ///
+ ///
+ /// This method should be fast and not perform heavy operations.
+ /// Typically checks the update type or presence of specific data.
+ ///
+ bool CanHandle(Update update);
+}
\ No newline at end of file
diff --git a/Bot/Hadlers/MessageHandler.cs b/Bot/Hadlers/MessageHandler.cs
new file mode 100755
index 0000000..ec0afc0
--- /dev/null
+++ b/Bot/Hadlers/MessageHandler.cs
@@ -0,0 +1,187 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Bot.Services;
+using Data.Models;
+using Microsoft.Extensions.Logging;
+using Services;
+using Telegram.Bot;
+using Telegram.Bot.Polling;
+using Telegram.Bot.Types;
+using Telegram.Bot.Types.Enums;
+
+namespace Bot.Handlers;
+
+///
+/// Handles incoming messages and routes them to appropriate processors based on message type and user state.
+///
+public class MessageHandler : IUpdateHandlerCommand
+{
+ private readonly ILogger logger;
+ private readonly IDonationService donationService;
+ private readonly IGoalService goalService;
+ private readonly CommandHandler commandHandler;
+ private readonly PaymentHandler paymentHandler;
+ private readonly UserStateService userStateService;
+ private readonly AdminHandler adminHandler;
+ private readonly AdminStateService adminStateService;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Logger instance for tracking operations.
+ /// Service for donation-related operations.
+ /// Service for goal-related operations.
+ /// Handler for command processing.
+ /// Handler for payment processing.
+ /// Service for managing user state.
+ /// Handler for administrative operations.
+ /// Service for managing admin state.
+ public MessageHandler(
+ ILogger logger,
+ IDonationService donationService,
+ IGoalService goalService,
+ CommandHandler commandHandler,
+ PaymentHandler paymentHandler,
+ UserStateService userStateService,
+ AdminHandler adminHandler,
+ AdminStateService adminStateService)
+ {
+ this.logger = logger;
+ this.donationService = donationService;
+ this.goalService = goalService;
+ this.commandHandler = commandHandler;
+ this.paymentHandler = paymentHandler;
+ this.userStateService = userStateService;
+ this.adminHandler = adminHandler;
+ this.adminStateService = adminStateService;
+
+ this.logger.LogDebug("MessageHandler initialized successfully");
+ }
+
+ ///
+ /// Determines whether this handler can process the update.
+ ///
+ /// The update to check.
+ /// True if the update contains a message; otherwise, false.
+ public bool CanHandle(Update update) => update.Message != null;
+
+ ///
+ /// Processes incoming messages and routes them based on message type and user state.
+ ///
+ /// Telegram bot client instance.
+ /// The update containing the message.
+ /// Cancellation token for async operations.
+ /// A representing the asynchronous operation.
+ public async Task HandleAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
+ {
+ var message = update.Message!;
+
+ if (message.From is null)
+ {
+ logger.LogWarning("Received message with null user information");
+ return;
+ }
+
+ var userId = message.From.Id;
+ var chatId = message.Chat.Id;
+
+ logger.LogDebug("Processing message from user {UserId} in chat {ChatId}", userId, chatId);
+
+ try
+ {
+ await RegisterOrUpdateUserAsync(message);
+
+ if (await ProcessSpecialMessageTypesAsync(botClient, message, cancellationToken))
+ {
+ return;
+ }
+
+ if (await ProcessUserStateBasedMessagesAsync(botClient, message, cancellationToken))
+ {
+ return;
+ }
+
+ await ProcessTextMessageAsync(botClient, message, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error processing message from user {UserId}", userId);
+ }
+ }
+
+ private async Task RegisterOrUpdateUserAsync(Message message)
+ {
+ try
+ {
+ await donationService.GetOrCreateUserAsync(
+ message.From.Id,
+ message.From.Username,
+ message.From.FirstName,
+ message.From.LastName);
+
+ logger.LogDebug("User {UserId} registered/updated successfully", message.From.Id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to register/update user {UserId}", message.From.Id);
+ }
+ }
+
+ private async Task ProcessSpecialMessageTypesAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken)
+ {
+ if (message.SuccessfulPayment != null)
+ {
+ logger.LogInformation("Processing successful payment from user {UserId}", message.From.Id);
+ await paymentHandler.HandleSuccessfulPaymentAsync(botClient, message, cancellationToken);
+ return true;
+ }
+
+ return false;
+ }
+
+ private async Task ProcessUserStateBasedMessagesAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken)
+ {
+ var userId = message.From.Id;
+ var chatId = message.Chat.Id;
+
+ if (userStateService.IsWaitingForAmount(userId, chatId))
+ {
+ logger.LogDebug("User {UserId} is in waiting for amount state", userId);
+ await paymentHandler.HandleCustomAmountInputAsync(botClient, message, cancellationToken);
+ return true;
+ }
+
+ var isAdmin = await goalService.IsUserAdminAsync(userId);
+ if (isAdmin && adminStateService.IsUserCreatingGoal(userId))
+ {
+ logger.LogDebug("Admin user {UserId} is in goal creation state", userId);
+ await adminHandler.HandleAdminGoalCreationAsync(botClient, message, cancellationToken);
+ return true;
+ }
+
+ return false;
+ }
+
+ private async Task ProcessTextMessageAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrEmpty(message.Text))
+ {
+ logger.LogDebug("Received non-text message from user {UserId}", message.From.Id);
+ return;
+ }
+
+ logger.LogInformation("Processing text message from user {UserId}: {MessageText}", message.From.Id, message.Text);
+
+ try
+ {
+ await commandHandler.HandleCommandAsync(botClient, message, cancellationToken);
+ logger.LogDebug("Text message processed successfully for user {UserId}", message.From.Id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error processing text message from user {UserId}", message.From.Id);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bot/Hadlers/PaymentHandler.cs b/Bot/Hadlers/PaymentHandler.cs
new file mode 100755
index 0000000..de91005
--- /dev/null
+++ b/Bot/Hadlers/PaymentHandler.cs
@@ -0,0 +1,283 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Bot.Services;
+using Configurations;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Services;
+using Telegram.Bot;
+using Telegram.Bot.Types;
+using Telegram.Bot.Types.Enums;
+using Telegram.Bot.Types.Payments;
+
+namespace Bot.Handlers;
+
+///
+/// Handles payment-related operations including donation invoices and payment processing.
+///
+public class PaymentHandler
+{
+ private const int MinimumDonationAmount = 60;
+ private const int MaximumDonationAmount = 100000;
+
+ private readonly ILogger logger;
+ private readonly IGoalService goalService;
+ private readonly IDonationService donationService;
+ private readonly UserStateService userStateService;
+ private readonly BotConfig botConfig;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Logger instance for tracking operations.
+ /// Service for goal-related operations.
+ /// Service for donation processing.
+ /// Service for managing user state.
+ /// Bot configuration options.
+ public PaymentHandler(
+ ILogger logger,
+ IGoalService goalService,
+ IDonationService donationService,
+ UserStateService userStateService,
+ IOptions botConfig)
+ {
+ this.logger = logger;
+ this.goalService = goalService;
+ this.donationService = donationService;
+ this.userStateService = userStateService;
+ this.botConfig = botConfig.Value;
+
+ this.logger.LogDebug("PaymentHandler initialized successfully");
+ }
+
+ ///
+ /// Processes custom donation amount input from users.
+ ///
+ /// Telegram bot client instance.
+ /// The message containing the custom amount.
+ /// Cancellation token for async operations.
+ /// A representing the asynchronous operation.
+ public virtual async Task HandleCustomAmountInputAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken)
+ {
+ if (message.From?.Id == null)
+ {
+ logger.LogWarning("Received custom amount input from null user");
+ return;
+ }
+
+ var userId = message.From.Id;
+ var chatId = message.Chat.Id;
+
+ logger.LogDebug("Processing custom amount input from user {UserId}", userId);
+
+ userStateService.RemoveWaitingForAmount(userId);
+
+ if (string.IsNullOrEmpty(message.Text))
+ {
+ logger.LogWarning("User {UserId} sent empty custom amount", userId);
+ await SendInvalidAmountMessageAsync(botClient, chatId, cancellationToken);
+ return;
+ }
+
+ if (!int.TryParse(message.Text, out int amount) || amount <= 0)
+ {
+ logger.LogWarning("User {UserId} sent invalid custom amount: {AmountText}", userId, message.Text);
+ await SendInvalidAmountMessageAsync(botClient, chatId, cancellationToken);
+ return;
+ }
+
+ await CreateDonationInvoice(botClient, chatId, userId, amount, cancellationToken);
+ }
+
+ ///
+ /// Creates a donation invoice for the specified amount.
+ ///
+ /// Telegram bot client instance.
+ /// Chat identifier.
+ /// User identifier.
+ /// Donation amount in rubles.
+ /// Cancellation token for async operations.
+ /// A representing the asynchronous operation.
+ public virtual async Task CreateDonationInvoice(ITelegramBotClient botClient, long chatId, long userId, int amountRub, CancellationToken cancellationToken)
+ {
+ logger.LogInformation("Creating donation invoice for user {UserId} with amount {AmountRub} RUB", userId, amountRub);
+
+ try
+ {
+ var goal = await goalService.GetActiveGoalAsync();
+ if (goal == null)
+ {
+ logger.LogWarning("No active goal found for donation from user {UserId}", userId);
+ await botClient.SendMessage(chatId, "❌ В данный момент нет активных целей для пожертвований.", cancellationToken: cancellationToken);
+ return;
+ }
+
+ if (!ValidateDonationAmount(amountRub))
+ {
+ logger.LogWarning("User {UserId} provided invalid donation amount: {AmountRub}", userId, amountRub);
+ await SendAmountValidationMessageAsync(botClient, chatId, cancellationToken);
+ return;
+ }
+
+ await SendDonationInvoiceAsync(botClient, chatId, userId, amountRub, goal, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error creating donation invoice for user {UserId}", userId);
+ await SendErrorMessageAsync(botClient, chatId, cancellationToken);
+ }
+ }
+
+ ///
+ /// Processes successful payments and updates donation records.
+ ///
+ /// Telegram bot client instance.
+ /// The message containing successful payment information.
+ /// Cancellation token for async operations.
+ /// A representing the asynchronous operation.
+ public virtual async Task HandleSuccessfulPaymentAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken)
+ {
+ if (message.SuccessfulPayment == null || message.From == null)
+ {
+ logger.LogWarning("Received successful payment with null payment or user information");
+ return;
+ }
+
+ var payment = message.SuccessfulPayment;
+ var user = message.From;
+ var chatId = message.Chat.Id;
+
+ logger.LogInformation("Processing successful payment from user {UserId}, charge ID: {ChargeId}", user.Id, payment.TelegramPaymentChargeId);
+
+ try
+ {
+ await DeleteInvoiceMessageAsync(botClient, chatId, message.MessageId);
+
+ var amount = payment.TotalAmount / 100m;
+ var success = await donationService.ProcessDonationAsync(user.Id, amount, payment.Currency, payment.TelegramPaymentChargeId);
+
+ if (success)
+ {
+ await SendThankYouMessageAsync(botClient, chatId, amount, payment.Currency, cancellationToken);
+ logger.LogInformation("Successfully processed donation from user {UserId}, amount: {Amount} {Currency}", user.Id, amount, payment.Currency);
+ }
+ else
+ {
+ await SendDonationProcessingErrorMessageAsync(botClient, chatId, cancellationToken);
+ logger.LogError("Failed to process donation from user {UserId}, charge ID: {ChargeId}", user.Id, payment.TelegramPaymentChargeId);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error handling successful payment from user {UserId}", user.Id);
+ await SendPaymentProcessingErrorMessageAsync(botClient, chatId, cancellationToken);
+ }
+ }
+
+ private bool ValidateDonationAmount(int amountRub)
+ {
+ if (amountRub > MaximumDonationAmount)
+ {
+ logger.LogWarning("Donation amount {AmountRub} exceeds maximum limit", amountRub);
+ return false;
+ }
+
+ if (amountRub < MinimumDonationAmount)
+ {
+ logger.LogWarning("Donation amount {AmountRub} below minimum limit", amountRub);
+ return false;
+ }
+
+ return true;
+ }
+
+ private async Task SendDonationInvoiceAsync(ITelegramBotClient botClient, long chatId, long userId, int amountRub, Data.Models.DonationGoal goal, CancellationToken cancellationToken)
+ {
+ var amountKopecks = amountRub * 100;
+ var prices = new[] { new LabeledPrice("Пожертвование", amountKopecks) };
+ var payload = $"donation_{goal.Id}_{userId}_{DateTime.UtcNow.Ticks}";
+
+ if (string.IsNullOrEmpty(botConfig.PaymentProviderToken))
+ {
+ logger.LogError("Payment provider token is not configured");
+ throw new InvalidOperationException("Payment provider token is not configured");
+ }
+
+ await botClient.SendInvoice(
+ chatId: chatId,
+ title: $"Пожертвование: {goal.Title}",
+ description: $"Пожертвование на сумму {amountRub} руб.",
+ payload: payload,
+ providerToken: botConfig.PaymentProviderToken,
+ currency: "RUB",
+ prices: prices,
+ cancellationToken: cancellationToken);
+
+ logger.LogInformation("Donation invoice created for user {UserId}, amount: {AmountRub} RUB, goal: {GoalTitle}", userId, amountRub, goal.Title);
+ }
+
+ private async Task DeleteInvoiceMessageAsync(ITelegramBotClient botClient, long chatId, int messageId)
+ {
+ try
+ {
+ await botClient.DeleteMessage(chatId, messageId);
+ logger.LogDebug("Invoice message {MessageId} deleted successfully", messageId);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Could not delete invoice message {MessageId}", messageId);
+ }
+ }
+
+ private async Task SendThankYouMessageAsync(ITelegramBotClient botClient, long chatId, decimal amount, string currency, CancellationToken cancellationToken)
+ {
+ var stats = await goalService.GetStartStats();
+ await botClient.SendMessage(
+ chatId: chatId,
+ text: $"✅ **Спасибо за ваше пожертвование!** \n\n💝 Вы пожертвовали: {amount:N2} {currency} \n{stats} \n\nВаша поддержка очень важна для нас!",
+ parseMode: ParseMode.Markdown,
+ cancellationToken: cancellationToken);
+ }
+
+ private async Task SendInvalidAmountMessageAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken)
+ {
+ await botClient.SendMessage(
+ chatId: chatId,
+ text: "❌ Пожалуйста, введите корректную сумму в рублях (только цифры)",
+ cancellationToken: cancellationToken);
+ }
+
+ private async Task SendAmountValidationMessageAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken)
+ {
+ await botClient.SendMessage(
+ chatId: chatId,
+ text: $"❌ Сумма пожертвования должна быть от {MinimumDonationAmount} до {MaximumDonationAmount} руб.",
+ cancellationToken: cancellationToken);
+ }
+
+ private async Task SendErrorMessageAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken)
+ {
+ await botClient.SendMessage(
+ chatId: chatId,
+ text: "❌ Произошла ошибка при создании платежа. Попробуйте позже.",
+ cancellationToken: cancellationToken);
+ }
+
+ private async Task SendDonationProcessingErrorMessageAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken)
+ {
+ await botClient.SendMessage(
+ chatId: chatId,
+ text: "❌ Произошла ошибка при обработке вашего платежа. Мы уже работаем над этим.",
+ cancellationToken: cancellationToken);
+ }
+
+ private async Task SendPaymentProcessingErrorMessageAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken)
+ {
+ await botClient.SendMessage(
+ chatId: chatId,
+ text: "❌ Произошла ошибка при обработке платежа. Мы уже работаем над этим.",
+ cancellationToken: cancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/Bot/Hadlers/PreCheckoutQueryHandler.cs b/Bot/Hadlers/PreCheckoutQueryHandler.cs
new file mode 100755
index 0000000..9c1e37a
--- /dev/null
+++ b/Bot/Hadlers/PreCheckoutQueryHandler.cs
@@ -0,0 +1,126 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Bot.Services;
+using Microsoft.Extensions.Logging;
+using Services;
+using Telegram.Bot;
+using Telegram.Bot.Types;
+using Telegram.Bot.Types.Payments;
+
+namespace Bot.Handlers;
+
+///
+/// Handles pre-checkout queries to validate payment requests before processing.
+///
+public class PreCheckoutQueryHandler : IUpdateHandlerCommand
+{
+ private readonly ILogger logger;
+ private readonly IGoalService goalService;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Logger instance for tracking operations.
+ /// Service for goal-related operations.
+ public PreCheckoutQueryHandler(
+ ILogger logger,
+ IGoalService goalService)
+ {
+ this.logger = logger;
+ this.goalService = goalService;
+
+ this.logger.LogDebug("PreCheckoutQueryHandler initialized successfully");
+ }
+
+ ///
+ /// Determines whether this handler can process the update.
+ ///
+ /// The update to check.
+ /// True if the update contains a pre-checkout query; otherwise, false.
+ public bool CanHandle(Update update) => update.PreCheckoutQuery != null;
+
+ ///
+ /// Processes pre-checkout queries to validate payment requests.
+ ///
+ /// Telegram bot client instance.
+ /// The update containing the pre-checkout query.
+ /// Cancellation token for async operations.
+ /// A representing the asynchronous operation.
+ public async Task HandleAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
+ {
+ var preCheckoutQuery = update.PreCheckoutQuery!;
+
+ if (preCheckoutQuery.From == null)
+ {
+ logger.LogWarning("Received pre-checkout query with null user information");
+ return;
+ }
+
+ logger.LogInformation(
+ "Processing pre-checkout query from user {UserId} with payload {InvoicePayload}", preCheckoutQuery.From.Id, preCheckoutQuery.InvoicePayload);
+
+ try
+ {
+ var goal = await goalService.GetActiveGoalAsync();
+
+ if (goal != null)
+ {
+ await botClient.AnswerPreCheckoutQuery(
+ preCheckoutQueryId: preCheckoutQuery.Id,
+ cancellationToken: cancellationToken);
+
+ logger.LogInformation(
+ "Approved pre-checkout query for user {UserId}, payload: {InvoicePayload}, amount: {Amount} {Currency}",
+ preCheckoutQuery.From.Id,
+ preCheckoutQuery.InvoicePayload,
+ preCheckoutQuery.TotalAmount / 100m,
+ preCheckoutQuery.Currency);
+ }
+ else
+ {
+ logger.LogWarning(
+ "Rejected pre-checkout query from user {UserId} - no active goal found",
+ preCheckoutQuery.From.Id);
+
+ await botClient.AnswerPreCheckoutQuery(
+ preCheckoutQueryId: preCheckoutQuery.Id,
+ errorMessage: "В данный момент нет активных целей для пожертвований",
+ cancellationToken: cancellationToken);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ ex,
+ "Error handling pre-checkout query from user {UserId}",
+ preCheckoutQuery.From.Id);
+
+ await SafeAnswerPreCheckoutQueryAsync(
+ botClient,
+ preCheckoutQuery.Id,
+ "Произошла ошибка при обработке платежа",
+ cancellationToken);
+ }
+ }
+
+ private async Task SafeAnswerPreCheckoutQueryAsync(
+ ITelegramBotClient botClient,
+ string preCheckoutQueryId,
+ string errorMessage,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await botClient.AnswerPreCheckoutQuery(
+ preCheckoutQueryId: preCheckoutQueryId,
+ errorMessage: errorMessage,
+ cancellationToken: cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to answer pre-checkout query {PreCheckoutQueryId}", preCheckoutQueryId);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bot/IReceiverService.cs b/Bot/IReceiverService.cs
new file mode 100755
index 0000000..e499c19
--- /dev/null
+++ b/Bot/IReceiverService.cs
@@ -0,0 +1,24 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Bot;
+
+///
+/// Defines a contract for receiving and processing updates in the Telegram bot.
+///
+public interface IReceiverService
+{
+ ///
+ /// Starts receiving and processing updates from the Telegram bot.
+ ///
+ /// Cancellation token to stop the receiving process.
+ /// A representing the asynchronous operation.
+ Task ReceiveAsync(CancellationToken stoppingToken);
+}
\ No newline at end of file
diff --git a/Bot/PollingService.cs b/Bot/PollingService.cs
new file mode 100755
index 0000000..aa0cc45
--- /dev/null
+++ b/Bot/PollingService.cs
@@ -0,0 +1,128 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Telegram.Bot;
+using Telegram.Bot.Exceptions;
+using Telegram.Bot.Polling;
+using Telegram.Bot.Types;
+using Telegram.Bot.Types.Enums;
+
+namespace Bot;
+
+///
+/// Background service for polling Telegram Bot API for updates.
+///
+public class PollingService : BackgroundService
+{
+ private readonly IServiceProvider serviceProvider;
+ private readonly ILogger logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The service provider for dependency injection.
+ /// Logger instance for tracking operations.
+ public PollingService(IServiceProvider serviceProvider, ILogger logger)
+ {
+ this.serviceProvider = serviceProvider;
+ this.logger = logger;
+ }
+
+ ///
+ /// Triggered when the application host is performing a graceful shutdown.
+ ///
+ /// Cancellation token indicating that shutdown should be no longer graceful.
+ /// A representing the asynchronous operation.
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ logger.LogInformation("Polling service is stopping gracefully");
+
+ await base.StopAsync(cancellationToken);
+
+ logger.LogInformation("Polling service stopped gracefully");
+ }
+
+ ///
+ /// Executes the polling service as a background task.
+ ///
+ /// Cancellation token to stop the service.
+ /// A representing the asynchronous operation.
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ logger.LogInformation("Polling service is starting");
+
+ try
+ {
+ await RunPollingLoopAsync(stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogCritical(ex, "Polling service encountered a critical error and is stopping");
+ }
+ finally
+ {
+ logger.LogInformation("Polling service has stopped");
+ }
+ }
+
+ private async Task RunPollingLoopAsync(CancellationToken stoppingToken)
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await ProcessPollingIterationAsync(stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ await HandlePollingErrorAsync(ex, stoppingToken);
+ }
+ }
+ }
+
+ private async Task ProcessPollingIterationAsync(CancellationToken stoppingToken)
+ {
+ // Create a new scope for each iteration to ensure fresh service instances
+ using var scope = serviceProvider.CreateScope();
+ var receiver = scope.ServiceProvider.GetRequiredService();
+
+ logger.LogDebug("Starting polling iteration");
+
+ await receiver.ReceiveAsync(stoppingToken);
+
+ logger.LogDebug("Completed polling iteration successfully");
+ }
+
+ private async Task HandlePollingErrorAsync(Exception ex, CancellationToken stoppingToken)
+ {
+ logger.LogError(ex, "Polling iteration failed with exception");
+
+ // Handle specific Telegram API exceptions
+ switch (ex)
+ {
+ case ApiRequestException apiEx:
+ logger.LogWarning("Telegram API request failed: {ErrorCode} - {Message}", apiEx.ErrorCode, apiEx.Message);
+ break;
+
+ case RequestException reqEx:
+ logger.LogWarning("Telegram request failed, possible network issue: {Message}", reqEx.Message);
+ break;
+ }
+
+ // Cooldown before retrying after errors
+ logger.LogInformation("Waiting 5 seconds before retrying after error");
+
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
+ }
+ catch (TaskCanceledException)
+ {
+ logger.LogDebug("Cooldown delay was cancelled");
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bot/Program.cs b/Bot/Program.cs
new file mode 100755
index 0000000..813ab9b
--- /dev/null
+++ b/Bot/Program.cs
@@ -0,0 +1,132 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Bot;
+using Bot.Handlers;
+using Bot.Services;
+using Configurations;
+using Data;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Services;
+using Telegram.Bot;
+using Telegram.Bot.Polling;
+
+internal class Program
+{
+ private static async Task Main(string[] args)
+ {
+ var builder = Host.CreateApplicationBuilder(args);
+
+ // Configure logging
+ builder.Logging.AddConsole();
+ builder.Logging.AddDebug();
+
+ // Add configuration
+ builder.Services.Configure(builder.Configuration.GetSection("BotConfig"));
+ builder.Services.Configure(builder.Configuration.GetSection("DatabaseConfig"));
+ builder.Configuration.AddUserSecrets();
+
+ // Validate critical configurations
+ ValidateConfiguration(builder);
+
+ // Register data layer services
+ builder.Services.AddScoped();
+
+ // Register business logic services
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+
+ // Register update handling infrastructure
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+
+ // Register command handlers
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+
+ // Register update handler commands
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+
+ // Register state management services
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+
+ // Register Telegram Bot Client
+ builder.Services.AddSingleton(sp =>
+ {
+ var config = sp.GetRequiredService>().Value;
+
+ if (string.IsNullOrEmpty(config.BotToken))
+ {
+ var logger = sp.GetRequiredService>();
+ logger.LogCritical("Bot token is not configured");
+ throw new InvalidOperationException("Bot token is not configured");
+ }
+
+ var botClient = new TelegramBotClient(config.BotToken);
+
+ var loggerFactory = sp.GetRequiredService();
+ var botLogger = loggerFactory.CreateLogger();
+ botLogger.LogInformation("Telegram Bot Client initialized successfully");
+
+ return botClient;
+ });
+
+ // Register background service
+ builder.Services.AddHostedService();
+
+ var host = builder.Build();
+
+ host.Run();
+ }
+
+ ///
+ /// Validates critical configuration settings required for application startup.
+ ///
+ /// The host application builder.
+ private static void ValidateConfiguration(HostApplicationBuilder builder)
+ {
+ var botConfig = builder.Configuration.GetSection("BotConfig").Get();
+ var databaseConfig = builder.Configuration.GetSection("DatabaseConfig").Get();
+
+ if (botConfig == null)
+ {
+ throw new InvalidOperationException("BotConfig section is missing from configuration");
+ }
+
+ if (string.IsNullOrEmpty(botConfig.BotToken))
+ {
+ throw new InvalidOperationException("BotToken is not configured in BotConfig");
+ }
+
+ if (string.IsNullOrEmpty(botConfig.PaymentProviderToken))
+ {
+ throw new InvalidOperationException("PaymentProviderToken is not configured in BotConfig");
+ }
+
+ if (databaseConfig == null)
+ {
+ throw new InvalidOperationException("DatabaseConfig section is missing from configuration");
+ }
+
+ if (string.IsNullOrEmpty(databaseConfig.ConnectionString))
+ {
+ throw new InvalidOperationException("ConnectionString is not configured in DatabaseConfig");
+ }
+
+ // Log validation success (we'll use a temporary logger since we don't have DI yet)
+ Console.WriteLine("Configuration validation completed successfully");
+ }
+}
\ No newline at end of file
diff --git a/Bot/ReceiverService.cs b/Bot/ReceiverService.cs
new file mode 100755
index 0000000..b5f936b
--- /dev/null
+++ b/Bot/ReceiverService.cs
@@ -0,0 +1,74 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Microsoft.Extensions.Logging;
+using Telegram.Bot;
+using Telegram.Bot.Polling;
+using Telegram.Bot.Types.Enums;
+
+namespace Bot;
+
+///
+/// Receiver service acting as a bridge between polling and update processing.
+///
+public class ReceiverService : IReceiverService
+{
+ private readonly ITelegramBotClient botClient;
+ private readonly IUpdateHandler updateHandler;
+ private readonly ILogger logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Telegram bot client instance.
+ /// Handler for processing updates.
+ /// Logger instance for tracking operations.
+ public ReceiverService(
+ ITelegramBotClient botClient,
+ IUpdateHandler updateHandler,
+ ILogger logger)
+ {
+ this.botClient = botClient;
+ this.updateHandler = updateHandler;
+ this.logger = logger;
+
+ this.logger.LogDebug("ReceiverService initialized");
+ }
+
+ ///
+ /// Starts receiving updates from Telegram Bot API.
+ ///
+ /// Cancellation token to stop receiving updates.
+ /// A representing the asynchronous operation.
+ public async Task ReceiveAsync(CancellationToken stoppingToken)
+ {
+ var receiverOptions = new ReceiverOptions()
+ {
+ AllowedUpdates = Array.Empty(),
+ DropPendingUpdates = true,
+ };
+
+ try
+ {
+ // Get bot information
+ var me = await botClient.GetMe(stoppingToken);
+ logger.LogInformation("Bot @{BotUsername} (ID: {BotId}) is starting to receive updates", me.Username, me.Id);
+
+ // Start receiving updates (blocking call)
+ logger.LogDebug("Starting to receive updates with configured options");
+
+ await botClient.ReceiveAsync(
+ updateHandler: updateHandler,
+ receiverOptions: receiverOptions,
+ cancellationToken: stoppingToken);
+
+ logger.LogInformation("Bot @{BotUsername} stopped receiving updates", me.Username);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error occurred while receiving updates");
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bot/Services/AdminGoalStep.cs b/Bot/Services/AdminGoalStep.cs
new file mode 100755
index 0000000..fbf88f5
--- /dev/null
+++ b/Bot/Services/AdminGoalStep.cs
@@ -0,0 +1,34 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+namespace Bot.Services;
+
+public partial class AdminStateService
+{
+ ///
+ /// Represents the steps in the admin goal creation workflow.
+ ///
+ public enum AdminGoalStep
+ {
+ ///
+ /// No active goal creation.
+ ///
+ None,
+
+ ///
+ /// Waiting for goal title input.
+ ///
+ WaitingForTitle,
+
+ ///
+ /// Waiting for goal description input.
+ ///
+ WaitingForDescription,
+
+ ///
+ /// Waiting for target amount input.
+ ///
+ WaitingForAmount,
+ }
+}
\ No newline at end of file
diff --git a/Bot/Services/AdminStateService.cs b/Bot/Services/AdminStateService.cs
new file mode 100755
index 0000000..402e170
--- /dev/null
+++ b/Bot/Services/AdminStateService.cs
@@ -0,0 +1,194 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Microsoft.Extensions.Logging;
+
+namespace Bot.Services;
+
+///
+/// Manages administrative state for goal creation workflow.
+///
+public partial class AdminStateService
+{
+ private readonly ILogger logger;
+ private readonly Dictionary adminStates = [];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Logger instance for tracking operations.
+ public AdminStateService(ILogger logger)
+ {
+ this.logger = logger;
+ this.logger.LogDebug("AdminStateService initialized");
+ }
+
+ ///
+ /// Starts the goal creation process for an admin user.
+ ///
+ /// The user identifier.
+ /// The chat identifier.
+ public virtual void StartGoalCreation(long userId, long chatId)
+ {
+ adminStates[userId] = new AdminGoalCreationState
+ {
+ ChatId = chatId,
+ CurrentStep = AdminGoalStep.WaitingForTitle,
+ };
+
+ logger.LogInformation("Started goal creation for admin user {UserId} in chat {ChatId}", userId, chatId);
+ }
+
+ ///
+ /// Retrieves the current state for an admin user.
+ ///
+ /// The user identifier.
+ /// The admin goal creation state if found; otherwise, null.
+ public virtual AdminGoalCreationState? GetState(long userId)
+ {
+ var stateExists = adminStates.TryGetValue(userId, out var state);
+
+ if (!stateExists)
+ {
+ logger.LogDebug("No state found for user {UserId}", userId);
+ }
+
+ return state;
+ }
+
+ ///
+ /// Sets the title for the goal being created and advances to the next step.
+ ///
+ /// The user identifier.
+ /// The goal title.
+ public virtual void SetTitle(long userId, string title)
+ {
+ if (adminStates.TryGetValue(userId, out var state))
+ {
+ state.Title = title;
+ state.CurrentStep = AdminGoalStep.WaitingForDescription;
+
+ logger.LogDebug("Set title for user {UserId}: {Title}", userId, title);
+ }
+ else
+ {
+ logger.LogWarning("Attempted to set title for non-existent user state: {UserId}", userId);
+ }
+ }
+
+ ///
+ /// Sets the description for the goal being created and advances to the next step.
+ ///
+ /// The user identifier.
+ /// The goal description.
+ public virtual void SetDescription(long userId, string description)
+ {
+ if (adminStates.TryGetValue(userId, out var state))
+ {
+ state.Description = description;
+ state.CurrentStep = AdminGoalStep.WaitingForAmount;
+
+ logger.LogDebug("Set description for user {UserId}", userId);
+ }
+ else
+ {
+ logger.LogWarning("Attempted to set description for non-existent user state: {UserId}", userId);
+ }
+ }
+
+ ///
+ /// Sets the target amount for the goal being created and completes the process.
+ ///
+ /// The user identifier.
+ /// The target amount.
+ public void SetAmount(long userId, decimal amount)
+ {
+ if (adminStates.TryGetValue(userId, out var state))
+ {
+ state.TargetAmount = amount;
+ state.CurrentStep = AdminGoalStep.None;
+
+ logger.LogDebug("Set amount for user {UserId}: {Amount}", userId, amount);
+ }
+ else
+ {
+ logger.LogWarning("Attempted to set amount for non-existent user state: {UserId}", userId);
+ }
+ }
+
+ ///
+ /// Cancels the goal creation process for a user.
+ ///
+ /// The user identifier.
+ public virtual void CancelGoalCreation(long userId)
+ {
+ var removed = adminStates.Remove(userId);
+
+ if (removed)
+ {
+ logger.LogInformation("Canceled goal creation for user {UserId}", userId);
+ }
+ else
+ {
+ logger.LogDebug("Attempted to cancel non-existent goal creation for user {UserId}", userId);
+ }
+ }
+
+ ///
+ /// Checks if a user is currently in the process of creating a goal.
+ ///
+ /// The user identifier.
+ /// True if the user is creating a goal; otherwise, false.
+ public virtual bool IsUserCreatingGoal(long userId)
+ {
+ var isCreating = adminStates.ContainsKey(userId) &&
+ adminStates[userId].CurrentStep != AdminGoalStep.None;
+
+ logger.LogDebug("User {UserId} goal creation status: {IsCreating}", userId, isCreating);
+
+ return isCreating;
+ }
+
+ ///
+ /// Gets the number of active admin states (for monitoring purposes).
+ ///
+ /// The count of active admin states.
+ public int GetActiveStateCount()
+ {
+ var count = adminStates.Count;
+ logger.LogTrace("Current active admin states: {Count}", count);
+ return count;
+ }
+
+ ///
+ /// Represents the state of an admin user during goal creation process.
+ ///
+ public class AdminGoalCreationState
+ {
+ ///
+ /// Gets or sets the chat identifier where goal creation is taking place.
+ ///
+ public long ChatId { get; set; }
+
+ ///
+ /// Gets or sets the goal title.
+ ///
+ public string? Title { get; set; }
+
+ ///
+ /// Gets or sets the goal description.
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// Gets or sets the target amount for the goal.
+ ///
+ public decimal? TargetAmount { get; set; }
+
+ ///
+ /// Gets or sets the current step in the goal creation process.
+ ///
+ public AdminGoalStep CurrentStep { get; set; } = AdminGoalStep.None;
+ }
+}
\ No newline at end of file
diff --git a/Bot/Services/KeyboardService.cs b/Bot/Services/KeyboardService.cs
new file mode 100755
index 0000000..f2d91e3
--- /dev/null
+++ b/Bot/Services/KeyboardService.cs
@@ -0,0 +1,135 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Microsoft.Extensions.Logging;
+using Telegram.Bot.Types.ReplyMarkups;
+
+namespace Bot.Services;
+
+///
+/// Service for creating and managing Telegram bot keyboards and markup.
+///
+public class KeyboardService
+{
+ private readonly ILogger logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Logger instance for tracking operations.
+ public KeyboardService(ILogger logger)
+ {
+ this.logger = logger;
+ this.logger.LogDebug("KeyboardService initialized");
+ }
+
+ ///
+ /// Creates the main menu keyboard for regular users.
+ ///
+ /// Configured reply keyboard markup for main menu.
+ public virtual ReplyKeyboardMarkup GetMainMenuKeyboard()
+ {
+ logger.LogDebug("Creating main menu keyboard for regular user");
+
+ return new ReplyKeyboardMarkup(
+ [
+ [new KeyboardButton("📊 Статистика"), new KeyboardButton("💳 Пожертвовать")],
+ [new KeyboardButton("🔄 Обновить")],
+ ])
+ {
+ ResizeKeyboard = true,
+ OneTimeKeyboard = false,
+ };
+ }
+
+ ///
+ /// Creates the main menu keyboard for admin users with administrative options.
+ ///
+ /// Configured reply keyboard markup for admin main menu.
+ public virtual ReplyKeyboardMarkup GetMainMenuKeyboardForAdmin()
+ {
+ logger.LogDebug("Creating main menu keyboard for admin user");
+
+ return new ReplyKeyboardMarkup(
+ [
+ [new KeyboardButton("📊 Статистика"), new KeyboardButton("📝 Создать новую цель")],
+ [new KeyboardButton("🔄 Обновить")],
+ ])
+ {
+ ResizeKeyboard = true,
+ OneTimeKeyboard = false,
+ };
+ }
+
+ ///
+ /// Creates an inline keyboard for donation amount selection.
+ ///
+ /// Configured inline keyboard markup for donation amounts.
+ public virtual InlineKeyboardMarkup GetDonationAmountKeyboard()
+ {
+ logger.LogDebug("Creating donation amount inline keyboard");
+
+ try
+ {
+ var keyboard = new InlineKeyboardMarkup(new[]
+ {
+ new[]
+ {
+ InlineKeyboardButton.WithCallbackData("100 ₽", "donate_100"),
+ InlineKeyboardButton.WithCallbackData("500 ₽", "donate_500"),
+ },
+ new[]
+ {
+ InlineKeyboardButton.WithCallbackData("1000 ₽", "donate_1000"),
+ InlineKeyboardButton.WithCallbackData("5000 ₽", "donate_5000"),
+ },
+ new[]
+ {
+ InlineKeyboardButton.WithCallbackData("💎 Другая сумма", "enter_custom_amount"),
+ },
+ });
+
+ logger.LogDebug("Donation amount keyboard created successfully");
+ return keyboard;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error creating donation amount keyboard");
+ throw;
+ }
+ }
+
+ ///
+ /// Creates a simple inline keyboard with a single back button.
+ ///
+ /// The callback data for the back button.
+ /// Configured inline keyboard markup with back button.
+ public InlineKeyboardMarkup GetBackButtonKeyboard(string callbackData = "back_to_main")
+ {
+ logger.LogDebug("Creating back button keyboard with callback data: {CallbackData}", callbackData);
+
+ return new InlineKeyboardMarkup(
+ InlineKeyboardButton.WithCallbackData("⬅️ Назад", callbackData));
+ }
+
+ ///
+ /// Creates a confirmation inline keyboard for important actions.
+ ///
+ /// Callback data for confirm action.
+ /// Callback data for cancel action.
+ /// Configured inline keyboard markup for confirmation.
+ public InlineKeyboardMarkup GetConfirmationKeyboard(string confirmCallbackData = "confirm", string cancelCallbackData = "cancel")
+ {
+ logger.LogDebug("Creating confirmation keyboard with confirm: {Confirm}, cancel: {Cancel}", confirmCallbackData, cancelCallbackData);
+
+ return new InlineKeyboardMarkup(new[]
+ {
+ new[]
+ {
+ InlineKeyboardButton.WithCallbackData("✅ Подтвердить", confirmCallbackData),
+ InlineKeyboardButton.WithCallbackData("❌ Отмена", cancelCallbackData),
+ },
+ });
+ }
+}
\ No newline at end of file
diff --git a/Bot/Services/UserStateService.cs b/Bot/Services/UserStateService.cs
new file mode 100755
index 0000000..d69abf0
--- /dev/null
+++ b/Bot/Services/UserStateService.cs
@@ -0,0 +1,114 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Microsoft.Extensions.Logging;
+
+namespace Bot.Services;
+
+///
+/// Manages user state for tracking user interactions and input expectations.
+///
+public class UserStateService
+{
+ private readonly ILogger logger;
+ private readonly Dictionary waitingForAmount = [];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Logger instance for tracking operations.
+ public UserStateService(ILogger logger)
+ {
+ this.logger = logger;
+ this.logger.LogDebug("UserStateService initialized");
+ }
+
+ ///
+ /// Sets a user to waiting for amount input state.
+ ///
+ /// The user identifier.
+ /// The chat identifier.
+ public virtual void SetWaitingForAmount(long userId, long chatId)
+ {
+ waitingForAmount[userId] = chatId;
+ logger.LogDebug("User {UserId} set to waiting for amount input in chat {ChatId}", userId, chatId);
+ }
+
+ ///
+ /// Checks if a user is waiting for amount input in the specified chat.
+ ///
+ /// The user identifier.
+ /// The chat identifier.
+ /// True if the user is waiting for amount input in the specified chat; otherwise, false.
+ public virtual bool IsWaitingForAmount(long userId, long chatId)
+ {
+ var isWaiting = waitingForAmount.TryGetValue(userId, out var waitingChatId) && waitingChatId == chatId;
+
+ logger.LogDebug(
+ "Checked waiting for amount status for user {UserId} in chat {ChatId}: {IsWaiting}", userId, chatId, isWaiting);
+
+ return isWaiting;
+ }
+
+ ///
+ /// Removes a user from waiting for amount input state.
+ ///
+ /// The user identifier.
+ public virtual void RemoveWaitingForAmount(long userId)
+ {
+ var removed = waitingForAmount.Remove(userId);
+
+ if (removed)
+ {
+ logger.LogDebug("Removed user {UserId} from waiting for amount state", userId);
+ }
+ else
+ {
+ logger.LogDebug("Attempted to remove non-existent waiting state for user {UserId}", userId);
+ }
+ }
+
+ ///
+ /// Gets the number of users currently waiting for amount input.
+ ///
+ /// The count of users in waiting for amount state.
+ public int GetWaitingUsersCount()
+ {
+ var count = waitingForAmount.Count;
+ logger.LogTrace("Current users waiting for amount input: {Count}", count);
+ return count;
+ }
+
+ ///
+ /// Clears all waiting states (for maintenance or reset scenarios).
+ ///
+ public void ClearAllWaitingStates()
+ {
+ var count = waitingForAmount.Count;
+ waitingForAmount.Clear();
+ logger.LogInformation("Cleared all waiting states, affected {Count} users", count);
+ }
+
+ ///
+ /// Removes waiting state for multiple users at once.
+ ///
+ /// The collection of user identifiers to remove.
+ /// The number of users successfully removed.
+ public int RemoveMultipleWaitingStates(IEnumerable userIds)
+ {
+ var removedCount = 0;
+
+ foreach (var userId in userIds)
+ {
+ if (waitingForAmount.Remove(userId))
+ {
+ removedCount++;
+ }
+ }
+
+ logger.LogDebug("Removed waiting states for {RemovedCount} out of {TotalCount} users", removedCount, userIds.Count());
+
+ return removedCount;
+ }
+}
\ No newline at end of file
diff --git a/Bot/UpdateHandler.cs b/Bot/UpdateHandler.cs
new file mode 100755
index 0000000..22acd4f
--- /dev/null
+++ b/Bot/UpdateHandler.cs
@@ -0,0 +1,131 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Bot.Handlers;
+using Microsoft.Extensions.Logging;
+using Telegram.Bot;
+using Telegram.Bot.Exceptions;
+using Telegram.Bot.Polling;
+using Telegram.Bot.Types;
+
+namespace Bot;
+
+///
+/// Handles incoming updates from Telegram Bot API and routes them to appropriate handlers.
+///
+public class UpdateHandler : IUpdateHandler
+{
+ private readonly ILogger logger;
+ private readonly IEnumerable handlers;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Logger instance for tracking operations.
+ /// Collection of update handler commands.
+ public UpdateHandler(
+ ILogger logger,
+ IEnumerable handlers)
+ {
+ this.logger = logger;
+ this.handlers = handlers;
+
+ this.logger.LogDebug("UpdateHandler initialized with {HandlerCount} handlers", handlers.Count());
+ }
+
+ ///
+ /// Processes incoming updates by routing them to appropriate handlers.
+ ///
+ /// Telegram bot client instance.
+ /// The update to process.
+ /// Cancellation token for async operations.
+ /// A representing the asynchronous operation.
+ public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
+ {
+ logger.LogDebug("Received update {UpdateId} of type {UpdateType}", update.Id, update.Type);
+
+ try
+ {
+ var handler = handlers.FirstOrDefault(h => h.CanHandle(update));
+
+ if (handler != null)
+ {
+ logger.LogDebug("Routing update {UpdateId} to handler {HandlerType}", update.Id, handler.GetType().Name);
+
+ await handler.HandleAsync(botClient, update, cancellationToken);
+ }
+ else
+ {
+ logger.LogWarning("No handler found for update {UpdateId} of type {UpdateType}", update.Id, update.Type);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error processing update {UpdateId} of type {UpdateType}", update.Id, update.Type);
+ }
+ }
+
+ ///
+ /// Handles errors that occur during update processing.
+ ///
+ /// Telegram bot client instance.
+ /// The exception that occurred.
+ /// The source of the error.
+ /// Cancellation token for async operations.
+ /// A representing the asynchronous operation.
+ public async Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, HandleErrorSource errorSource, CancellationToken cancellationToken)
+ {
+ var errorMessage = exception switch
+ {
+ ApiRequestException apiRequestException
+ => $"Telegram API Error: [{apiRequestException.ErrorCode}] {apiRequestException.Message}",
+ RequestException requestException
+ => $"Request Error: {requestException.Message}",
+ _ => exception.Message
+ };
+
+ logger.LogError(exception, "Error occurred from source {ErrorSource}. Message: {ErrorMessage}", errorSource, errorMessage);
+
+ // Implement retry delay for network-related errors
+ if (exception is HttpRequestException or RequestException)
+ {
+ logger.LogInformation("Network error detected, waiting 1 second before retry");
+
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
+ }
+ catch (TaskCanceledException)
+ {
+ logger.LogDebug("Retry delay was cancelled");
+ }
+ }
+
+ // Log specific API error codes for monitoring
+ if (exception is ApiRequestException apiEx)
+ {
+ LogApiErrorDetails(apiEx);
+ }
+ }
+
+ ///
+ /// Logs detailed information about Telegram API errors for monitoring and debugging.
+ ///
+ /// The API request exception.
+ private void LogApiErrorDetails(ApiRequestException apiException)
+ {
+ var logLevel = apiException.ErrorCode switch
+ {
+ 400 => LogLevel.Warning, // Bad Request
+ 401 => LogLevel.Critical, // Unauthorized
+ 403 => LogLevel.Warning, // Forbidden
+ 404 => LogLevel.Information, // Not Found
+ 429 => LogLevel.Warning, // Too Many Requests
+ 500 => LogLevel.Error, // Internal Server Error
+ _ => LogLevel.Error
+ };
+
+ logger.Log(logLevel, apiException, "Telegram API Error {ErrorCode}: {ErrorMessage}", apiException.ErrorCode, apiException.Message);
+ }
+}
\ No newline at end of file
diff --git a/Bot/appsettings.json b/Bot/appsettings.json
new file mode 100755
index 0000000..987fd3a
--- /dev/null
+++ b/Bot/appsettings.json
@@ -0,0 +1,15 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "BotConfig": {
+ "BotToken": "",
+ "PaymentProviderToken": ""
+ },
+ "DatabaseConfig": {
+ "ConnectionString": ""
+ }
+}
\ No newline at end of file
diff --git a/Configurations/BotConfig.cs b/Configurations/BotConfig.cs
new file mode 100755
index 0000000..f13e1e4
--- /dev/null
+++ b/Configurations/BotConfig.cs
@@ -0,0 +1,27 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+namespace Configurations;
+
+///
+/// Represents the configuration settings for the bot application.
+///
+public class BotConfig
+{
+ ///
+ /// Gets or sets the bot token used for authenticating with the Telegram Bot API.
+ ///
+ ///
+ /// The bot token string. This should be kept secure and not exposed publicly.
+ ///
+ public string BotToken { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the payment provider token for processing donations.
+ ///
+ ///
+ /// The payment provider token string. Used for integrating with payment systems.
+ ///
+ public string PaymentProviderToken { get; set; } = string.Empty;
+}
diff --git a/Configurations/Configurations.csproj b/Configurations/Configurations.csproj
new file mode 100755
index 0000000..21bcc02
--- /dev/null
+++ b/Configurations/Configurations.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/Configurations/DatabaseConfig.cs b/Configurations/DatabaseConfig.cs
new file mode 100755
index 0000000..e8ab896
--- /dev/null
+++ b/Configurations/DatabaseConfig.cs
@@ -0,0 +1,28 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Configurations;
+
+///
+/// Represents database configuration settings for the application.
+///
+public class DatabaseConfig
+{
+ ///
+ /// Gets or sets the connection string used to connect to the database.
+ ///
+ ///
+ /// The connection string containing server, database, and authentication information.
+ ///
+ ///
+ /// Server=localhost;Database=MyDb;UserId=myuser;Password=mypassword;.
+ ///
+ public string ConnectionString { get; set; } = string.Empty;
+}
diff --git a/Data/DapperRepository.cs b/Data/DapperRepository.cs
new file mode 100755
index 0000000..efcf7d8
--- /dev/null
+++ b/Data/DapperRepository.cs
@@ -0,0 +1,347 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Configurations;
+using Dapper;
+using Data.Models;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Npgsql;
+
+namespace Data;
+
+///
+/// Provides data access operations for the application using Dapper micro-ORM.
+/// Implements the interface.
+///
+public class DapperRepository : IDapperRepository
+{
+ private readonly string connectionString;
+ private readonly ILogger logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Database configuration containing the connection string.
+ /// Logger instance for tracking operations.
+ public DapperRepository(IOptions config, ILogger logger)
+ {
+ connectionString = config.Value.ConnectionString;
+ this.logger = logger;
+ this.logger.LogDebug("DapperRepository initialized");
+ }
+
+ ///
+ /// Retrieves a user by their Telegram identifier.
+ ///
+ /// The Telegram user identifier to search for.
+ ///
+ /// A object if found; otherwise, null.
+ ///
+ public async Task GetUserByTelegramIdAsync(long telegramId)
+ {
+ logger.LogDebug("Retrieving user by Telegram ID: {TelegramId}", telegramId);
+
+ try
+ {
+ using var connection = new NpgsqlConnection(connectionString);
+ var user = await connection.QueryFirstOrDefaultAsync(
+ "SELECT * FROM users WHERE telegram_id = @TelegramId",
+ new { TelegramId = telegramId });
+
+ logger.LogDebug("User retrieval {(Result)} for Telegram ID: {TelegramId}", user != null ? "succeeded" : "failed - not found", telegramId);
+
+ return user;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error retrieving user by Telegram ID: {TelegramId}", telegramId);
+ throw;
+ }
+ }
+
+ ///
+ /// Creates a new user in the database.
+ ///
+ /// The user object containing the data to insert.
+ ///
+ /// The created object with the generated identifier.
+ ///
+ public async Task CreateUserAsync(Users user)
+ {
+ logger.LogDebug("Creating user with Telegram ID: {TelegramId}", user.TelegramId);
+
+ try
+ {
+ using var connection = new NpgsqlConnection(connectionString);
+ var sql = @"
+ INSERT INTO users (telegram_id, username, first_name, last_name)
+ VALUES (@TelegramId, @Username, @FirstName, @LastName)
+ RETURNING *";
+
+ var createdUser = await connection.QueryFirstAsync(sql, user);
+ logger.LogInformation(
+ "User created successfully with ID: {UserId} for Telegram ID: {TelegramId}", createdUser.Id, user.TelegramId);
+
+ return createdUser;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error creating user with Telegram ID: {TelegramId}", user.TelegramId);
+ throw;
+ }
+ }
+
+ ///
+ /// Retrieves the currently active donation goal.
+ ///
+ ///
+ /// An active object if found; otherwise, null.
+ ///
+ public async Task GetActiveGoalAsync()
+ {
+ logger.LogDebug("Retrieving active donation goal");
+
+ try
+ {
+ using var connection = new NpgsqlConnection(connectionString);
+ var sql = @"
+ SELECT
+ id AS Id,
+ title AS Title,
+ description AS Description,
+ target_amount AS TargetAmount,
+ current_amount AS CurrentAmount,
+ is_active AS IsActive,
+ created_at AS CreatedAt
+ FROM donation_goals
+ WHERE is_active = true
+ ORDER BY created_at DESC
+ LIMIT 1";
+
+ var goal = await connection.QueryFirstOrDefaultAsync(sql);
+ logger.LogDebug(
+ "Active goal retrieval {(Result)}", goal != null ? $"succeeded - Goal ID: {goal.Id}" : "failed - no active goal");
+
+ return goal;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error retrieving active donation goal");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets the total number of donations made to active goals.
+ ///
+ ///
+ /// The count of donations for active goals.
+ ///
+ public async Task GetCountDonationsForActiveGoals()
+ {
+ logger.LogDebug("Retrieving count of donations for active goals");
+
+ try
+ {
+ using var connection = new NpgsqlConnection(connectionString);
+ var sql = @"
+ SELECT COUNT(*)
+ FROM donations d
+ INNER JOIN donation_goals g ON d.goal_id = g.id
+ WHERE g.is_active = true";
+
+ var count = await connection.QueryFirstOrDefaultAsync(sql);
+ logger.LogDebug("Retrieved {DonationCount} donations for active goals", count);
+
+ return count;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error retrieving count of donations for active goals");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets the number of unique users who have donated to active goals.
+ ///
+ ///
+ /// The count of unique users for active goals.
+ ///
+ public async Task GetCountUsersForActiveGoals()
+ {
+ logger.LogDebug("Retrieving count of unique users for active goals");
+
+ try
+ {
+ using var connection = new NpgsqlConnection(connectionString);
+ var sql = @"
+ SELECT COUNT(DISTINCT d.user_telegram_id)
+ FROM donations d
+ INNER JOIN donation_goals g ON d.goal_id = g.id
+ WHERE g.is_active = true";
+
+ var count = await connection.QueryFirstOrDefaultAsync(sql);
+ logger.LogDebug("Retrieved {UserCount} unique users for active goals", count);
+
+ return count;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error retrieving count of unique users for active goals");
+ throw;
+ }
+ }
+
+ ///
+ /// Creates a new donation goal and deactivates any previously active goal.
+ ///
+ /// The donation goal object containing the data to insert.
+ ///
+ /// The created object with the generated identifier.
+ ///
+ public async Task CreateGoalAsync(DonationGoal goal)
+ {
+ logger.LogInformation("Creating new donation goal: {GoalTitle}", goal.Title);
+
+ try
+ {
+ using var connection = new NpgsqlConnection(connectionString);
+ var sql = @"
+ UPDATE donation_goals SET is_active = false WHERE is_active = true;
+
+ INSERT INTO donation_goals (title, description, target_amount)
+ VALUES (@Title, @Description, @TargetAmount)
+ RETURNING *";
+
+ var createdGoal = await connection.QueryFirstAsync(sql, goal);
+ logger.LogInformation(
+ "Goal created successfully with ID: {GoalId}, Target: {TargetAmount}", createdGoal.Id, createdGoal.TargetAmount);
+
+ return createdGoal;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error creating donation goal: {GoalTitle}", goal.Title);
+ throw;
+ }
+ }
+
+ ///
+ /// Updates the current amount of a donation goal by adding the specified amount.
+ ///
+ /// The identifier of the goal to update.
+ /// The amount to add to the goal's current amount.
+ public async Task UpdateGoalCurrentAmountAsync(int goalId, decimal amountToAdd)
+ {
+ logger.LogDebug("Updating goal {GoalId} current amount by {AmountToAdd}", goalId, amountToAdd);
+
+ try
+ {
+ using var connection = new NpgsqlConnection(connectionString);
+ var sql = @"
+ UPDATE donation_goals
+ SET current_amount = current_amount + @AmountToAdd
+ WHERE id = @GoalId";
+
+ var affectedRows = await connection.ExecuteAsync(sql, new { GoalId = goalId, AmountToAdd = amountToAdd });
+
+ if (affectedRows == 0)
+ {
+ logger.LogWarning("No rows affected when updating goal {GoalId} amount", goalId);
+ }
+ else
+ {
+ logger.LogDebug("Successfully updated goal {GoalId} amount by {AmountToAdd}", goalId, amountToAdd);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error updating goal {GoalId} current amount by {AmountToAdd}", goalId, amountToAdd);
+ throw;
+ }
+ }
+
+ ///
+ /// Creates a new donation record in the database.
+ ///
+ /// The donation object containing the data to insert.
+ ///
+ /// The created object with the generated identifier.
+ ///
+ public async Task CreateDonationAsync(Donation donation)
+ {
+ logger.LogDebug(
+ "Creating donation for user {UserTelegramId} with amount {Amount} {Currency}", donation.UserTelegramId, donation.Amount, donation.Currency);
+
+ try
+ {
+ using var connection = new NpgsqlConnection(connectionString);
+ var sql = @"
+ INSERT INTO donations (user_telegram_id, goal_id, amount, currency, provider_payment_id, status)
+ VALUES (@UserTelegramId, @GoalId, @Amount, @Currency, @ProviderPaymentId, @Status)
+ RETURNING
+ id AS Id,
+ user_telegram_id AS UserTelegramId,
+ goal_id AS GoalId,
+ amount AS Amount,
+ currency AS Currency,
+ status AS Status,
+ provider_payment_id AS ProviderPaymentId,
+ created_at AS CreatedAt";
+
+ var createdDonation = await connection.QueryFirstAsync(sql, donation);
+ logger.LogInformation(
+ "Donation created successfully with ID: {DonationId} for user {UserTelegramId}", createdDonation.Id, donation.UserTelegramId);
+
+ return createdDonation;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error creating donation for user {UserTelegramId}", donation.UserTelegramId);
+ throw;
+ }
+ }
+
+ ///
+ /// Retrieves a donation by its payment provider identifier.
+ ///
+ /// The payment provider's donation identifier.
+ ///
+ /// A object if found; otherwise, null.
+ ///
+ public async Task GetDonationAsync(string donationId)
+ {
+ logger.LogDebug("Retrieving donation by ID: {DonationId}", donationId);
+
+ try
+ {
+ using var connection = new NpgsqlConnection(connectionString);
+ var sql = @"
+ SELECT
+ id AS Id,
+ user_telegram_id AS UserTelegramId,
+ goal_id AS GoalId,
+ amount AS Amount,
+ currency AS Currency,
+ provider_payment_id AS ProviderPaymentId,
+ status AS Status,
+ created_at AS CreatedAt
+ FROM donations
+ WHERE provider_payment_id = @DonationId";
+
+ var donation = await connection.QueryFirstOrDefaultAsync(sql, new { DonationId = donationId });
+ logger.LogDebug(
+ "Donation retrieval {(Result)} for ID: {DonationId}", donation != null ? "succeeded" : "failed - not found", donationId);
+
+ return donation;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error retrieving donation by ID: {DonationId}", donationId);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Data/Data.csproj b/Data/Data.csproj
new file mode 100755
index 0000000..a7b4289
--- /dev/null
+++ b/Data/Data.csproj
@@ -0,0 +1,23 @@
+
+
+ 10.0
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Data/IDapperRepository.cs b/Data/IDapperRepository.cs
new file mode 100755
index 0000000..a68732f
--- /dev/null
+++ b/Data/IDapperRepository.cs
@@ -0,0 +1,74 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Data.Models;
+
+namespace Data;
+
+///
+/// Repository interface for data access operations using Dapper ORM.
+///
+public interface IDapperRepository
+{
+ ///
+ /// Retrieves a user by their Telegram identifier.
+ ///
+ /// The Telegram user identifier.
+ /// The user if found; otherwise, null.
+ Task GetUserByTelegramIdAsync(long telegramId);
+
+ ///
+ /// Creates a new user in the database.
+ ///
+ /// The user entity to create.
+ /// The created user with generated identifier.
+ Task CreateUserAsync(Users user);
+
+ ///
+ /// Retrieves the currently active donation goal.
+ ///
+ /// The active donation goal if found; otherwise, null.
+ Task GetActiveGoalAsync();
+
+ ///
+ /// Gets the count of unique users who donated to active goals.
+ ///
+ /// The number of unique donors for active goals.
+ Task GetCountUsersForActiveGoals();
+
+ ///
+ /// Gets the total count of donations made to active goals.
+ ///
+ /// The total number of donations for active goals.
+ Task GetCountDonationsForActiveGoals();
+
+ ///
+ /// Creates a new donation goal in the database.
+ ///
+ /// The donation goal entity to create.
+ /// The created donation goal with generated identifier.
+ Task CreateGoalAsync(DonationGoal goal);
+
+ ///
+ /// Updates the current collected amount for a specific goal.
+ ///
+ /// The identifier of the goal to update.
+ /// The new current amount value.
+ /// A task representing the asynchronous operation.
+ Task UpdateGoalCurrentAmountAsync(int goalId, decimal newAmount);
+
+ ///
+ /// Creates a new donation record in the database.
+ ///
+ /// The donation entity to create.
+ /// The created donation with generated identifier.
+ Task CreateDonationAsync(Donation donation);
+
+ ///
+ /// Retrieves a donation by its unique identifier.
+ ///
+ /// The donation identifier.
+ /// The donation if found; otherwise, null.
+ Task GetDonationAsync(string donationId);
+}
\ No newline at end of file
diff --git a/Data/Models/Donation.cs b/Data/Models/Donation.cs
new file mode 100755
index 0000000..0a40b84
--- /dev/null
+++ b/Data/Models/Donation.cs
@@ -0,0 +1,75 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+namespace Data.Models;
+
+///
+/// Represents a donation made by a user towards a specific goal.
+///
+public class Donation
+{
+ ///
+ /// Gets or sets the unique identifier for the donation.
+ ///
+ ///
+ /// The donation identifier.
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Gets or sets the Telegram user identifier of the donor.
+ ///
+ ///
+ /// The Telegram user identifier.
+ ///
+ public long UserTelegramId { get; set; }
+
+ ///
+ /// Gets or sets the identifier of the goal this donation is for.
+ ///
+ ///
+ /// The goal identifier.
+ ///
+ public int GoalId { get; set; }
+
+ ///
+ /// Gets or sets the amount of the donation.
+ ///
+ ///
+ /// The donation amount in the specified currency.
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// Gets or sets the currency of the donation. Default is "RUB" (Russian Ruble).
+ ///
+ ///
+ /// The 3-letter currency code (ISO 4217).
+ ///
+ public string Currency { get; set; } = "RUB";
+
+ ///
+ /// Gets or sets the payment provider's transaction identifier.
+ ///
+ ///
+ /// The payment provider's unique transaction identifier, or null if not available.
+ ///
+ public string? ProviderPaymentId { get; set; }
+
+ ///
+ /// Gets or sets the current status of the donation. Default is "pending".
+ ///
+ ///
+ /// The donation status (e.g., "pending", "completed", "failed", "refunded").
+ ///
+ public string Status { get; set; } = "pending";
+
+ ///
+ /// Gets or sets the date and time when the donation was created.
+ ///
+ ///
+ /// The creation timestamp in UTC.
+ ///
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+}
\ No newline at end of file
diff --git a/Data/Models/DonationGoal.cs b/Data/Models/DonationGoal.cs
new file mode 100755
index 0000000..e54efc5
--- /dev/null
+++ b/Data/Models/DonationGoal.cs
@@ -0,0 +1,67 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+namespace Data.Models;
+
+///
+/// Represents a fundraising goal with a target amount and current progress.
+///
+public class DonationGoal
+{
+ ///
+ /// Gets or sets the unique identifier for the donation goal.
+ ///
+ ///
+ /// The goal identifier.
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Gets or sets the title of the donation goal.
+ ///
+ ///
+ /// The goal title.
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the optional description providing more details about the goal.
+ ///
+ ///
+ /// The goal description, or null if not provided.
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// Gets or sets the target amount to be raised for this goal.
+ ///
+ ///
+ /// The target amount in the base currency.
+ ///
+ public decimal TargetAmount { get; set; }
+
+ ///
+ /// Gets or sets the current amount raised towards the target.
+ ///
+ ///
+ /// The current amount raised.
+ ///
+ public decimal CurrentAmount { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the goal is currently active and accepting donations.
+ ///
+ ///
+ /// true if the goal is active; otherwise, false.
+ ///
+ public bool IsActive { get; set; } = true;
+
+ ///
+ /// Gets or sets the date and time when the goal was created.
+ ///
+ ///
+ /// The creation timestamp in UTC.
+ ///
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/Data/Models/Users.cs b/Data/Models/Users.cs
new file mode 100755
index 0000000..abf0b9f
--- /dev/null
+++ b/Data/Models/Users.cs
@@ -0,0 +1,46 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+namespace Data.Models;
+
+///
+/// Represents a user in the system.
+///
+public class Users
+{
+ ///
+ /// Gets or sets the unique identifier for the user.
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the user has administrative privileges.
+ ///
+ public bool Admin { get; set; }
+
+ ///
+ /// Gets or sets the user's Telegram identifier.
+ ///
+ public long TelegramId { get; set; }
+
+ ///
+ /// Gets or sets the user's Telegram username (optional).
+ ///
+ public string? Username { get; set; }
+
+ ///
+ /// Gets or sets the user's first name (optional).
+ ///
+ public string? FirstName { get; set; }
+
+ ///
+ /// Gets or sets the user's last name (optional).
+ ///
+ public string? LastName { get; set; }
+
+ ///
+ /// Gets or sets the date and time when the user record was created.
+ ///
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+}
\ No newline at end of file
diff --git a/Data/Scripts/create_tables.sql b/Data/Scripts/create_tables.sql
new file mode 100755
index 0000000..1816359
--- /dev/null
+++ b/Data/Scripts/create_tables.sql
@@ -0,0 +1,49 @@
+CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ telegram_id BIGINT NOT NULL UNIQUE,
+ username VARCHAR(100),
+ first_name VARCHAR(100),
+ last_name VARCHAR(100),
+ admin BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS donation_goals (
+ id SERIAL PRIMARY KEY,
+ title VARCHAR(255) NOT NULL,
+ description TEXT,
+ target_amount DECIMAL(10, 2) NOT NULL,
+ current_amount DECIMAL(10, 2) DEFAULT 0,
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS donations (
+ id SERIAL PRIMARY KEY,
+ user_telegram_id BIGINT NOT NULL,
+ goal_id INTEGER NOT NULL,
+ amount DECIMAL(10, 2) NOT NULL,
+ currency VARCHAR(3) DEFAULT 'RUB',
+ provider_payment_id VARCHAR(255) NOT NULL,
+ status VARCHAR(50) DEFAULT 'pending',
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+
+ -- Ограничения
+ CONSTRAINT chk_donation_amount_positive CHECK (amount > 0),
+ CONSTRAINT uq_provider_payment_id UNIQUE (provider_payment_id),
+
+ -- Внешние ключи
+ CONSTRAINT fk_donations_user_id
+ FOREIGN KEY (user_telegram_id)
+ REFERENCES users(telegram_id) ON DELETE RESTRICT,
+
+ CONSTRAINT fk_donations_goal_id
+ FOREIGN KEY (goal_id)
+ REFERENCES donation_goals(id) ON DELETE RESTRICT
+);
+
+-- Индексы
+CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id);
+CREATE INDEX IF NOT EXISTS idx_donation_goals_active ON donation_goals(is_active) WHERE is_active = true;
+CREATE INDEX IF NOT EXISTS idx_donations_user_telegram_id ON donations(user_telegram_id);
+CREATE INDEX IF NOT EXISTS idx_donations_goal_id ON donations(goal_id);
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100755
index 0000000..23e7710
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,20 @@
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+WORKDIR /src
+
+# Копируем файлы проектов
+COPY ["Bot/Bot.csproj", "Bot/"]
+COPY ["Data/Data.csproj", "Data/"]
+COPY ["Services/Services.csproj", "Services/"]
+COPY ["Configurations/Configurations.csproj", "Configurations/"]
+RUN dotnet restore "Bot/Bot.csproj"
+
+# Копируем весь код и публикуем
+COPY . .
+WORKDIR "/src/Bot"
+RUN dotnet publish "Bot.csproj" -c Release -o /app/publish
+
+# Финальный образ
+FROM mcr.microsoft.com/dotnet/aspnet:9.0
+WORKDIR /app
+COPY --from=build /app/publish .
+ENTRYPOINT ["dotnet", "Bot.dll"]
\ No newline at end of file
diff --git a/DonationBot.sln b/DonationBot.sln
new file mode 100755
index 0000000..b8f5dad
--- /dev/null
+++ b/DonationBot.sln
@@ -0,0 +1,103 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data", "Data\Data.csproj", "{5562041A-E13F-4D1F-8D4A-F275232C57CB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Services", "Services\Services.csproj", "{62104FDE-4949-4FB5-B509-541CF8078D02}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Configurations", "Configurations\Configurations.csproj", "{499B7406-86EF-4C81-995B-1ABAD3CD7BA1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bot", "Bot\Bot.csproj", "{BCABA8EA-B3EF-4B25-9481-9CAA38D95379}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Элементы решения", "Элементы решения", "{754FC069-D67B-A9D7-50A1-8D1CA196D8F1}"
+ ProjectSection(SolutionItems) = preProject
+ docker-compose.yml = docker-compose.yml
+ EndProjectSection
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestBot", "TestBot\TestBot.csproj", "{164F484F-C030-47B5-A8FC-E3FEA0E20318}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{36D591C7-65C7-A0D1-1CBC-10CDE441BDC8}"
+ ProjectSection(SolutionItems) = preProject
+ .editorconfig = .editorconfig
+ EndProjectSection
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Debug|x64.Build.0 = Debug|Any CPU
+ {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Debug|x86.Build.0 = Debug|Any CPU
+ {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Release|x64.ActiveCfg = Release|Any CPU
+ {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Release|x64.Build.0 = Release|Any CPU
+ {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Release|x86.ActiveCfg = Release|Any CPU
+ {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Release|x86.Build.0 = Release|Any CPU
+ {62104FDE-4949-4FB5-B509-541CF8078D02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {62104FDE-4949-4FB5-B509-541CF8078D02}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {62104FDE-4949-4FB5-B509-541CF8078D02}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {62104FDE-4949-4FB5-B509-541CF8078D02}.Debug|x64.Build.0 = Debug|Any CPU
+ {62104FDE-4949-4FB5-B509-541CF8078D02}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {62104FDE-4949-4FB5-B509-541CF8078D02}.Debug|x86.Build.0 = Debug|Any CPU
+ {62104FDE-4949-4FB5-B509-541CF8078D02}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {62104FDE-4949-4FB5-B509-541CF8078D02}.Release|Any CPU.Build.0 = Release|Any CPU
+ {62104FDE-4949-4FB5-B509-541CF8078D02}.Release|x64.ActiveCfg = Release|Any CPU
+ {62104FDE-4949-4FB5-B509-541CF8078D02}.Release|x64.Build.0 = Release|Any CPU
+ {62104FDE-4949-4FB5-B509-541CF8078D02}.Release|x86.ActiveCfg = Release|Any CPU
+ {62104FDE-4949-4FB5-B509-541CF8078D02}.Release|x86.Build.0 = Release|Any CPU
+ {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Debug|x64.Build.0 = Debug|Any CPU
+ {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Debug|x86.Build.0 = Debug|Any CPU
+ {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Release|x64.ActiveCfg = Release|Any CPU
+ {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Release|x64.Build.0 = Release|Any CPU
+ {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Release|x86.ActiveCfg = Release|Any CPU
+ {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Release|x86.Build.0 = Release|Any CPU
+ {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Debug|x64.Build.0 = Debug|Any CPU
+ {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Debug|x86.Build.0 = Debug|Any CPU
+ {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Release|x64.ActiveCfg = Release|Any CPU
+ {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Release|x64.Build.0 = Release|Any CPU
+ {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Release|x86.ActiveCfg = Release|Any CPU
+ {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Release|x86.Build.0 = Release|Any CPU
+ {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Debug|x64.Build.0 = Debug|Any CPU
+ {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Debug|x86.Build.0 = Debug|Any CPU
+ {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Release|Any CPU.Build.0 = Release|Any CPU
+ {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Release|x64.ActiveCfg = Release|Any CPU
+ {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Release|x64.Build.0 = Release|Any CPU
+ {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Release|x86.ActiveCfg = Release|Any CPU
+ {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {021B4F17-39EA-4135-981B-3A3D4D1EA000}
+ EndGlobalSection
+EndGlobal
diff --git a/Services/DonationService.cs b/Services/DonationService.cs
new file mode 100755
index 0000000..17fbb0d
--- /dev/null
+++ b/Services/DonationService.cs
@@ -0,0 +1,157 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Data;
+using Data.Models;
+using Microsoft.Extensions.Logging;
+
+namespace Services;
+
+///
+/// Provides services for managing user donations and user data operations.
+/// Implements the interface.
+///
+public class DonationService : IDonationService
+{
+ private readonly IDapperRepository repository;
+ private readonly ILogger logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Data repository for database operations.
+ /// Logger instance for tracking operations.
+ public DonationService(IDapperRepository repository, ILogger logger)
+ {
+ this.repository = repository;
+ this.logger = logger;
+ this.logger.LogDebug("DonationService initialized");
+ }
+
+ ///
+ /// Retrieves an existing user by Telegram ID or creates a new user if not found.
+ ///
+ /// The Telegram user identifier.
+ /// The Telegram username (optional).
+ /// The user's first name (optional).
+ /// The user's last name (optional).
+ ///
+ /// The existing or newly created object.
+ ///
+ ///
+ /// This method implements a "get or create" pattern for user management.
+ ///
+ public async Task GetOrCreateUserAsync(long telegramId, string? username, string? firstName, string? lastName)
+ {
+ logger.LogDebug("Getting or creating user with Telegram ID: {TelegramId}", telegramId);
+
+ try
+ {
+ var user = await repository.GetUserByTelegramIdAsync(telegramId);
+
+ if (user != null)
+ {
+ logger.LogDebug("Found existing user {UserId} for Telegram ID: {TelegramId}", user.Id, telegramId);
+ return user;
+ }
+
+ logger.LogInformation(
+ "Creating new user for Telegram ID: {TelegramId}, username: {Username}", telegramId, username);
+
+ var newUser = new Users
+ {
+ TelegramId = telegramId,
+ Username = username,
+ FirstName = firstName,
+ LastName = lastName,
+ Admin = false,
+ };
+
+ var createdUser = await repository.CreateUserAsync(newUser);
+ logger.LogInformation(
+ "Created new user {UserId} for Telegram ID: {TelegramId}", createdUser.Id, telegramId);
+
+ return createdUser;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting or creating user with Telegram ID: {TelegramId}", telegramId);
+ throw;
+ }
+ }
+
+ ///
+ /// Processes a donation transaction and updates the relevant goal's progress.
+ ///
+ /// The Telegram identifier of the user making the donation.
+ /// The donation amount.
+ /// The currency code of the donation.
+ /// The unique identifier provided by the payment provider.
+ ///
+ /// true if the donation was processed successfully; otherwise, false.
+ ///
+ public async Task ProcessDonationAsync(long userTelegramId, decimal amount, string currency, string donationId)
+ {
+ logger.LogInformation(
+ "Processing donation {DonationId} for user {UserTelegramId}, amount: {Amount} {Currency}", donationId, userTelegramId, amount, currency);
+
+ try
+ {
+ var existingDonation = await repository.GetDonationAsync(donationId);
+ if (existingDonation != null)
+ {
+ logger.LogWarning(
+ "Donation {DonationId} has already been processed on {ProcessedDate}", donationId, existingDonation.CreatedAt);
+ return true;
+ }
+
+ var goal = await repository.GetActiveGoalAsync();
+ if (goal == null)
+ {
+ logger.LogError("No active goal found for donation {DonationId}", donationId);
+ return false;
+ }
+
+ logger.LogDebug("Found active goal {GoalId} for donation {DonationId}", goal.Id, donationId);
+
+ var donation = new Donation
+ {
+ UserTelegramId = userTelegramId,
+ GoalId = goal.Id,
+ Amount = amount,
+ Currency = currency,
+ ProviderPaymentId = donationId,
+ Status = "completed",
+ };
+
+ var createdDonation = await repository.CreateDonationAsync(donation);
+ logger.LogDebug(
+ "Created donation record {DonationRecordId} for payment {DonationId}", createdDonation.Id, donationId);
+
+ await repository.UpdateGoalCurrentAmountAsync(goal.Id, amount);
+
+ logger.LogInformation(
+ "Successfully processed donation {DonationId} for user {UserTelegramId}. " +
+ "Amount: {Amount} {Currency} added to goal {GoalId}",
+ donationId,
+ userTelegramId,
+ amount,
+ currency,
+ goal.Id);
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ ex,
+ "Error processing donation {DonationId} for user {UserTelegramId}, amount: {Amount} {Currency}",
+ donationId,
+ userTelegramId,
+ amount,
+ currency);
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Services/GoalService.cs b/Services/GoalService.cs
new file mode 100755
index 0000000..9e233f6
--- /dev/null
+++ b/Services/GoalService.cs
@@ -0,0 +1,228 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Data;
+using Data.Models;
+using Microsoft.Extensions.Logging;
+
+namespace Services;
+
+///
+/// Service implementation for handling goal-related operations including statistics and administrative functions.
+///
+public class GoalService : IGoalService
+{
+ private readonly IDapperRepository repository;
+ private readonly ILogger logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The repository for data access operations.
+ /// Logger instance for tracking operations.
+ public GoalService(IDapperRepository repository, ILogger logger)
+ {
+ this.repository = repository;
+ this.logger = logger;
+
+ this.logger.LogDebug("GoalService initialized");
+ }
+
+ ///
+ public async Task IsUserAdminAsync(long telegramId)
+ {
+ logger.LogDebug("Checking admin status for user {TelegramId}", telegramId);
+
+ try
+ {
+ var user = await repository.GetUserByTelegramIdAsync(telegramId);
+ var isAdmin = user?.Admin ?? false;
+
+ logger.LogDebug("User {TelegramId} admin status: {IsAdmin}", telegramId, isAdmin);
+
+ return isAdmin;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error checking admin status for user {TelegramId}", telegramId);
+ return false;
+ }
+ }
+
+ ///
+ public async Task GetActiveGoalAsync()
+ {
+ logger.LogDebug("Retrieving active goal");
+
+ try
+ {
+ var goal = await repository.GetActiveGoalAsync();
+
+ logger.LogDebug("Active goal retrieval {(Result)}", goal != null ? $"succeeded - Goal ID: {goal.Id}" : "failed - no active goal");
+
+ return goal;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error retrieving active goal");
+ return null;
+ }
+ }
+
+ ///
+ public async Task GetGoalStatsAsync()
+ {
+ logger.LogDebug("Generating goal statistics");
+
+ try
+ {
+ var goal = await GetActiveGoalAsync();
+ var countUsers = await GetActiveUsersGoalAsync();
+ var countDonations = await GetActiveDonationsGoalAsync();
+
+ if (goal == null)
+ {
+ logger.LogInformation("No active goal found for statistics");
+ return "🎯 На данный момент нет активных целей для сбора.";
+ }
+
+ var percent = goal.TargetAmount > 0
+ ? ((double)goal.CurrentAmount / (double)goal.TargetAmount) * 100
+ : 0;
+
+ var progressBar = CreateProgressBar(percent);
+
+ var stats = $"🎯 **{goal.Title}** — {goal.TargetAmount:N0}₽ \n📝 Описание: {goal.Description} \n\n📈 Количество пожертвований на текущую цель: {countDonations}" +
+ $"\n🧮 Количество пожертвовавших: {countUsers} \n⏳ Дата открытия сбора: {goal.CreatedAt:dd.MM.yyyy}\n\nСобрано: {goal.CurrentAmount:N0}₽ ({percent:F1}%) \n{progressBar}";
+
+ logger.LogDebug("Goal statistics generated successfully for goal {GoalId}", goal.Id);
+
+ return stats;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error generating goal statistics");
+ return "❌ Произошла ошибка при получении статистики.";
+ }
+ }
+
+ ///
+ public async Task GetStartStats()
+ {
+ logger.LogDebug("Generating start statistics");
+
+ try
+ {
+ var goal = await GetActiveGoalAsync();
+
+ if (goal == null)
+ {
+ logger.LogInformation("No active goal found for start statistics");
+ return "🎯 На данный момент нет активных целей для сбора.";
+ }
+
+ var percent = goal.TargetAmount > 0
+ ? ((double)goal.CurrentAmount / (double)goal.TargetAmount) * 100
+ : 0;
+
+ var progressBar = CreateProgressBar(percent);
+
+ var stats = $"🎯 **{goal.Title}** — {goal.TargetAmount:N0}₽ \n📝 Описание: {goal.Description} \n\nСобрано: {goal.CurrentAmount:N0}₽ ({percent:F1}%) \n{progressBar}";
+
+ logger.LogDebug("Start statistics generated successfully for goal {GoalId}", goal.Id);
+
+ return stats;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error generating start statistics");
+ return "❌ Произошла ошибка при получении статистики.";
+ }
+ }
+
+ ///
+ public async Task CreateGoalAsync(string title, string description, decimal targetAmount)
+ {
+ logger.LogInformation("Creating new goal: {Title}, Target: {TargetAmount}", title, targetAmount);
+
+ try
+ {
+ var newGoal = new DonationGoal
+ {
+ Title = title,
+ Description = description,
+ TargetAmount = targetAmount,
+ IsActive = true,
+ };
+
+ var createdGoal = await repository.CreateGoalAsync(newGoal);
+
+ logger.LogInformation("Goal created successfully: {GoalId}, {Title}, Target: {TargetAmount}", createdGoal.Id, title, targetAmount);
+
+ return createdGoal;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error creating goal: {Title}", title);
+ throw;
+ }
+ }
+
+ private async Task GetActiveUsersGoalAsync()
+ {
+ logger.LogDebug("Retrieving active users count for goal");
+
+ try
+ {
+ var count = await repository.GetCountUsersForActiveGoals();
+
+ logger.LogDebug("Retrieved {UserCount} active users for goal", count);
+
+ return count;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error retrieving active users count for goal");
+ return 0;
+ }
+ }
+
+ private async Task GetActiveDonationsGoalAsync()
+ {
+ logger.LogDebug("Retrieving active donations count for goal");
+
+ try
+ {
+ var count = await repository.GetCountDonationsForActiveGoals();
+
+ logger.LogDebug("Retrieved {DonationCount} active donations for goal", count);
+
+ return count;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error retrieving active donations count for goal");
+ return 0;
+ }
+ }
+
+ ///
+ /// Creates a visual progress bar representation.
+ ///
+ /// The completion percentage.
+ /// A string representing the progress bar.
+ private string CreateProgressBar(double percent)
+ {
+ if (percent >= 100)
+ {
+ return $"[{new string('■', 10)}]";
+ }
+ else
+ {
+ var filled = (int)Math.Round(percent / 10);
+ var empty = 10 - filled;
+ return $"[{new string('■', filled)}{new string('□', empty)}]";
+ }
+ }
+}
\ No newline at end of file
diff --git a/Services/IDonationService.cs b/Services/IDonationService.cs
new file mode 100755
index 0000000..4a2ef3f
--- /dev/null
+++ b/Services/IDonationService.cs
@@ -0,0 +1,33 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Data.Models;
+
+namespace Services;
+
+///
+/// Service for handling donation-related operations and user management.
+///
+public interface IDonationService
+{
+ ///
+ /// Retrieves an existing user or creates a new one if not found.
+ ///
+ /// The Telegram user identifier.
+ /// The Telegram username (optional).
+ /// The user's first name (optional).
+ /// The user's last name (optional).
+ /// The existing or newly created user.
+ Task GetOrCreateUserAsync(long telegramId, string? username, string? firstName, string? lastName);
+
+ ///
+ /// Processes a donation transaction and updates relevant records.
+ ///
+ /// The identifier of the user making the donation.
+ /// The donation amount.
+ /// The currency of the donation.
+ /// The unique payment provider identifier for the donation.
+ /// True if the donation was processed successfully; otherwise, false.
+ Task ProcessDonationAsync(long userId, decimal amount, string currency, string donationId);
+}
\ No newline at end of file
diff --git a/Services/IGoalService.cs b/Services/IGoalService.cs
new file mode 100755
index 0000000..f820dc5
--- /dev/null
+++ b/Services/IGoalService.cs
@@ -0,0 +1,47 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Data.Models;
+
+namespace Services;
+
+///
+/// Service for handling goal-related operations including statistics and administrative functions.
+///
+public interface IGoalService
+{
+ ///
+ /// Checks if a user has administrator privileges.
+ ///
+ /// The Telegram user identifier.
+ /// True if the user is an administrator; otherwise, false.
+ Task IsUserAdminAsync(long telegramId);
+
+ ///
+ /// Retrieves the currently active donation goal.
+ ///
+ /// The active donation goal if found; otherwise, null.
+ Task GetActiveGoalAsync();
+
+ ///
+ /// Gets startup statistics for display in the welcome message.
+ ///
+ /// Formatted statistics string for the start command.
+ Task GetStartStats();
+
+ ///
+ /// Gets detailed statistics for the current goal.
+ ///
+ /// Formatted statistics string for the stats command.
+ Task GetGoalStatsAsync();
+
+ ///
+ /// Creates a new donation goal with the specified parameters.
+ ///
+ /// The goal title.
+ /// The goal description.
+ /// The target amount for the goal.
+ /// The created donation goal.
+ Task CreateGoalAsync(string title, string description, decimal targetAmount);
+}
\ No newline at end of file
diff --git a/Services/Services.csproj b/Services/Services.csproj
new file mode 100755
index 0000000..8f42233
--- /dev/null
+++ b/Services/Services.csproj
@@ -0,0 +1,21 @@
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
diff --git a/TestBot/Bot/Bot.Hadler/AdminHandlerTests.cs b/TestBot/Bot/Bot.Hadler/AdminHandlerTests.cs
new file mode 100755
index 0000000..aa3f1d6
--- /dev/null
+++ b/TestBot/Bot/Bot.Hadler/AdminHandlerTests.cs
@@ -0,0 +1,498 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Bot.Handlers;
+using Bot.Services;
+using Microsoft.Extensions.Logging;
+using Moq;
+using NUnit.Framework;
+using Services;
+using Telegram.Bot;
+using Telegram.Bot.Types;
+using static Bot.Services.AdminStateService;
+
+namespace Bot.Tests.Handlers;
+
+///
+/// Unit tests for the class.
+///
+[TestFixture]
+public class AdminHandlerTests
+{
+ private Mock> loggerMock;
+ private Mock goalServiceMock;
+ private Mock adminStateServiceMock;
+ private Mock botClientMock;
+ private AdminHandler handler;
+
+ ///
+ /// Sets up the test environment before each test execution.
+ ///
+ [SetUp]
+ public void Setup()
+ {
+ loggerMock = new Mock>();
+ goalServiceMock = new Mock();
+
+ // Создаем мок для AdminStateService с правильным конструктором
+ var adminStateServiceLoggerMock = new Mock>();
+ adminStateServiceMock = new Mock(adminStateServiceLoggerMock.Object);
+
+ botClientMock = new Mock();
+
+ handler = new AdminHandler(
+ loggerMock.Object,
+ goalServiceMock.Object,
+ adminStateServiceMock.Object);
+ }
+
+ ///
+ /// Tests that a message with null user triggers a warning log and returns without processing.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAdminGoalCreationAsync_NullUser_LogsWarningAndReturns()
+ {
+ // Arrange
+ var message = new Message { From = null, Chat = new Chat { Id = 123 }, Text = "Test" };
+ var cancellationToken = CancellationToken.None;
+
+ // Act
+ await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken);
+
+ // Assert
+ loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString() !.Contains("Received message with null user ID")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that a message from a user without admin state triggers a warning log and returns.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAdminGoalCreationAsync_NullState_LogsWarningAndReturns()
+ {
+ // Arrange
+ var user = new User { Id = 123 };
+ var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "Test" };
+ var cancellationToken = CancellationToken.None;
+
+ adminStateServiceMock
+ .Setup(x => x.GetState(123))
+ .Returns(null as AdminGoalCreationState);
+
+ // Act
+ await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken);
+
+ // Assert
+ loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString() !.Contains("No admin state found for user")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that a message with empty text triggers a warning log and returns.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAdminGoalCreationAsync_EmptyMessageText_LogsWarningAndReturns()
+ {
+ // Arrange
+ var user = new User { Id = 123 };
+ var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = null };
+ var state = new AdminGoalCreationState { CurrentStep = AdminGoalStep.WaitingForTitle };
+ var cancellationToken = CancellationToken.None;
+
+ adminStateServiceMock
+ .Setup(x => x.GetState(123))
+ .Returns(state);
+
+ // Act
+ await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken);
+
+ // Assert
+ loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString() !.Contains("Received empty message text")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that a valid title is processed correctly during the title step.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAdminGoalCreationAsync_WaitingForTitle_ValidTitle_ProcessesTitleStep()
+ {
+ // Arrange
+ var user = new User { Id = 123 };
+ var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "Новая цель" };
+ var state = new AdminGoalCreationState { CurrentStep = AdminGoalStep.WaitingForTitle };
+ var cancellationToken = CancellationToken.None;
+
+ adminStateServiceMock
+ .Setup(x => x.GetState(123))
+ .Returns(state);
+
+ // Act
+ await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken);
+
+ // Assert
+ adminStateServiceMock.Verify(
+ x => x.SetTitle(123, "Новая цель"),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that a title exceeding the length limit triggers cancellation of goal creation.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAdminGoalCreationAsync_WaitingForTitle_TooLongTitle_CancelsCreation()
+ {
+ // Arrange
+ var user = new User { Id = 123 };
+ var longTitle = new string('a', 256);
+ var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = longTitle };
+ var state = new AdminGoalCreationState { CurrentStep = AdminGoalStep.WaitingForTitle };
+ var cancellationToken = CancellationToken.None;
+
+ adminStateServiceMock
+ .Setup(x => x.GetState(123))
+ .Returns(state);
+
+ // Act
+ await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken);
+
+ // Assert
+ adminStateServiceMock.Verify(
+ x => x.CancelGoalCreation(123),
+ Times.Once);
+
+ loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString() !.Contains("provided title that exceeds length limit")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that a valid description is processed correctly during the description step.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAdminGoalCreationAsync_WaitingForDescription_ProcessesDescriptionStep()
+ {
+ // Arrange
+ var user = new User { Id = 123 };
+ var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "Описание цели" };
+ var state = new AdminGoalCreationState { CurrentStep = AdminGoalStep.WaitingForDescription };
+ var cancellationToken = CancellationToken.None;
+
+ adminStateServiceMock
+ .Setup(x => x.GetState(123))
+ .Returns(state);
+
+ // Act
+ await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken);
+
+ // Assert
+ adminStateServiceMock.Verify(
+ x => x.SetDescription(123, "Описание цели"),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that an invalid amount triggers cancellation of goal creation.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAdminGoalCreationAsync_WaitingForAmount_InvalidAmount_CancelsCreation()
+ {
+ // Arrange
+ var user = new User { Id = 123 };
+ var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "invalid_amount" };
+ var state = new AdminGoalCreationState
+ {
+ CurrentStep = AdminGoalStep.WaitingForAmount,
+ Title = "Test Goal",
+ Description = "Test Description",
+ };
+ var cancellationToken = CancellationToken.None;
+
+ adminStateServiceMock
+ .Setup(x => x.GetState(123))
+ .Returns(state);
+
+ // Act
+ await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken);
+
+ // Assert
+ adminStateServiceMock.Verify(
+ x => x.CancelGoalCreation(123),
+ Times.Once);
+
+ loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString() !.Contains("provided invalid amount")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that an amount exceeding the maximum limit triggers cancellation of goal creation.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAdminGoalCreationAsync_WaitingForAmount_AmountTooLarge_CancelsCreation()
+ {
+ // Arrange
+ var user = new User { Id = 123 };
+ var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "100000000" };
+ var state = new AdminGoalCreationState
+ {
+ CurrentStep = AdminGoalStep.WaitingForAmount,
+ Title = "Test Goal",
+ Description = "Test Description",
+ };
+ var cancellationToken = CancellationToken.None;
+
+ adminStateServiceMock
+ .Setup(x => x.GetState(123))
+ .Returns(state);
+
+ // Act
+ await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken);
+
+ // Assert
+ adminStateServiceMock.Verify(
+ x => x.CancelGoalCreation(123),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that a valid amount triggers successful goal creation.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAdminGoalCreationAsync_WaitingForAmount_ValidAmount_CreatesGoal()
+ {
+ // Arrange
+ var user = new User { Id = 123 };
+ var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "5000" };
+ var state = new AdminGoalCreationState
+ {
+ CurrentStep = AdminGoalStep.WaitingForAmount,
+ Title = "Test Goal",
+ Description = "Test Description",
+ };
+ var cancellationToken = CancellationToken.None;
+
+ var createdGoal = new Data.Models.DonationGoal { Id = 1, Title = "Test Goal", Description = "Test Description", TargetAmount = 5000 };
+ adminStateServiceMock
+ .Setup(x => x.GetState(123))
+ .Returns(state);
+
+ goalServiceMock
+ .Setup(x => x.CreateGoalAsync("Test Goal", "Test Description", 5000))
+ .ReturnsAsync(createdGoal);
+
+ // Act
+ await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken);
+
+ // Assert
+ goalServiceMock.Verify(
+ x => x.CreateGoalAsync("Test Goal", "Test Description", 5000),
+ Times.Once);
+
+ adminStateServiceMock.Verify(
+ x => x.CancelGoalCreation(123),
+ Times.Once);
+
+ loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString() !.Contains("Goal created successfully")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that an exception in goal service triggers error logging.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAdminGoalCreationAsync_WaitingForAmount_GoalServiceThrows_LogsError()
+ {
+ // Arrange
+ var user = new User { Id = 123 };
+ var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "5000" };
+ var state = new AdminGoalCreationState
+ {
+ CurrentStep = AdminGoalStep.WaitingForAmount,
+ Title = "Test Goal",
+ Description = "Test Description",
+ };
+ var cancellationToken = CancellationToken.None;
+
+ adminStateServiceMock
+ .Setup(x => x.GetState(123))
+ .Returns(state);
+
+ goalServiceMock
+ .Setup(x => x.CreateGoalAsync("Test Goal", "Test Description", 5000))
+ .ThrowsAsync(new Exception("Database error"));
+
+ // Act
+ await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken);
+
+ // Assert
+ loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString() !.Contains("Failed to create goal in database")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that starting goal creation sets the admin state and logs appropriately.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task StartGoalCreationAsync_SetsStateAndLogs()
+ {
+ // Arrange
+ var chatId = 456L;
+ var userId = 123L;
+ var cancellationToken = CancellationToken.None;
+
+ // Act
+ await handler.StartGoalCreationAsync(botClientMock.Object, chatId, userId, cancellationToken);
+
+ // Assert
+ adminStateServiceMock.Verify(
+ x => x.StartGoalCreation(123, 456),
+ Times.Once);
+
+ loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString() !.Contains("Starting goal creation process")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that an exception during goal creation start is properly logged.
+ ///
+ [Test]
+ public void StartGoalCreationAsyncThrowsExceptionLogsError()
+ {
+ // Arrange
+ var chatId = 456L;
+ var userId = 123L;
+ var cancellationToken = CancellationToken.None;
+
+ adminStateServiceMock
+ .Setup(x => x.StartGoalCreation(123, 456))
+ .Throws(new Exception("State service error"));
+
+ // Act & Assert
+ Assert.ThrowsAsync(() =>
+ handler.StartGoalCreationAsync(botClientMock.Object, chatId, userId, cancellationToken));
+
+ loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString() !.Contains("Failed to start goal creation")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that non-admin access attempts are properly logged.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleNotAdmin_LogsWarning()
+ {
+ // Arrange
+ var chatId = 456L;
+ var cancellationToken = CancellationToken.None;
+
+ // Act
+ await handler.HandleNotAdmin(botClientMock.Object, chatId, cancellationToken);
+
+ // Assert
+ loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString() !.Contains("Non-admin access attempt detected")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that an unknown admin step triggers a warning log.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAdminGoalCreationAsync_UnknownStep_LogsWarning()
+ {
+ // Arrange
+ var user = new User { Id = 123 };
+ var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "Test" };
+ var state = new AdminGoalCreationState { CurrentStep = (AdminGoalStep)999 };
+ var cancellationToken = CancellationToken.None;
+
+ adminStateServiceMock
+ .Setup(x => x.GetState(123))
+ .Returns(state);
+
+ // Act
+ await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken);
+
+ // Assert
+ loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString() !.Contains("Unknown admin step")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+}
\ No newline at end of file
diff --git a/TestBot/Bot/Bot.Hadler/CallbackQueryHandlerTests.cs b/TestBot/Bot/Bot.Hadler/CallbackQueryHandlerTests.cs
new file mode 100755
index 0000000..84679fa
--- /dev/null
+++ b/TestBot/Bot/Bot.Hadler/CallbackQueryHandlerTests.cs
@@ -0,0 +1,437 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Bot.Handlers;
+using Bot.Services;
+using Configurations;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using NUnit.Framework;
+using Services;
+using System.Reflection.Metadata;
+using Telegram.Bot;
+using Telegram.Bot.Types;
+
+namespace Bot.Tests.Handlers;
+
+///
+/// Unit tests for the class.
+///
+[TestFixture]
+public class CallbackQueryHandlerTests
+{
+ private Mock> loggerMock;
+ private Mock paymentHandlerMock;
+ private Mock commandHandlerMock;
+ private Mock userStateServiceMock;
+ private Mock botClientMock;
+ private CallbackQueryHandler handler;
+
+ ///
+ /// Sets up the test environment before each test execution.
+ ///
+ [SetUp]
+ public void Setup()
+ {
+ this.loggerMock = new Mock>();
+
+ // Явно создаем мок логгера для UserStateService
+ var userStateServiceLoggerMock = new Mock>();
+ this.userStateServiceMock = new Mock(userStateServiceLoggerMock.Object);
+
+ // Создаем моки для зависимостей PaymentHandler
+ var paymentLoggerMock = new Mock>();
+ var goalServiceMock = new Mock();
+ var donationServiceMock = new Mock();
+ var userStateServiceForPaymentMock = new Mock(Mock.Of>());
+ var botConfigMock = new Mock>();
+ botConfigMock.Setup(x => x.Value).Returns(new BotConfig
+ {
+ PaymentProviderToken = "test-token",
+ });
+
+ this.paymentHandlerMock = new Mock(
+ paymentLoggerMock.Object,
+ goalServiceMock.Object,
+ donationServiceMock.Object,
+ userStateServiceForPaymentMock.Object,
+ botConfigMock.Object);
+
+ // Создаем моки для зависимостей CommandHandler
+ var commandLoggerMock = new Mock>();
+ var commandGoalServiceMock = new Mock();
+ var keyboardServiceMock = new Mock(Mock.Of>());
+
+ // Создаем мок для AdminStateService
+ var adminStateServiceLoggerMock = new Mock>();
+ var adminStateServiceMock = new Mock(adminStateServiceLoggerMock.Object);
+
+ // Создаем мок для AdminHandler
+ var adminHandlerMock = new Mock(
+ Mock.Of>(),
+ Mock.Of(),
+ adminStateServiceMock.Object);
+
+ this.commandHandlerMock = new Mock(
+ commandLoggerMock.Object,
+ commandGoalServiceMock.Object,
+ keyboardServiceMock.Object,
+ adminHandlerMock.Object);
+
+ this.botClientMock = new Mock();
+
+ // Настраиваем бизнес-методы
+ this.paymentHandlerMock
+ .Setup(x => x.CreateDonationInvoice(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ this.commandHandlerMock
+ .Setup(x => x.HandleStatsCommand(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ this.userStateServiceMock
+ .Setup(x => x.SetWaitingForAmount(
+ It.IsAny(),
+ It.IsAny()))
+ .Verifiable();
+
+ this.handler = new CallbackQueryHandler(
+ this.loggerMock.Object,
+ this.paymentHandlerMock.Object,
+ this.commandHandlerMock.Object,
+ this.userStateServiceMock.Object);
+ }
+
+ ///
+ /// Tests that the handler correctly identifies updates with callback queries.
+ ///
+ [Test]
+ public void CanHandleWithCallbackQueryReturnsTrue()
+ {
+ // Arrange
+ var update = new Update { CallbackQuery = new CallbackQuery() };
+
+ // Act
+ var result = this.handler.CanHandle(update);
+
+ // Assert
+ Assert.That(result, Is.True);
+ }
+
+ ///
+ /// Tests that the handler correctly identifies updates without callback queries.
+ ///
+ [Test]
+ public void CanHandleWithoutCallbackQueryReturnsFalse()
+ {
+ // Arrange
+ var update = new Update { CallbackQuery = null };
+
+ // Act
+ var result = this.handler.CanHandle(update);
+
+ // Assert
+ Assert.That(result, Is.False);
+ }
+
+ ///
+ /// Tests that a callback query without chat information does not trigger further processing.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAsyncNoChatInformationDoesNotProcessFurther()
+ {
+ // Arrange
+ var callbackQuery = new CallbackQuery
+ {
+ Id = "test_query_id",
+ From = new User { Id = 123 },
+ Data = "donate_100",
+ Message = null, // No message/chat information
+ };
+ var update = new Update { CallbackQuery = callbackQuery };
+ var cancellationToken = CancellationToken.None;
+
+ // Act
+ await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken);
+
+ // Assert - убеждаемся, что бизнес-логика не вызывалась
+ this.paymentHandlerMock.Verify(
+ x => x.CreateDonationInvoice(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ ///
+ /// Tests that a callback query with empty data does not trigger further processing.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAsyncEmptyCallbackDataDoesNotProcessFurther()
+ {
+ // Arrange
+ var callbackQuery = new CallbackQuery
+ {
+ Id = "test_query_id",
+ From = new User { Id = 123 },
+ Data = null, // Empty callback data
+ Message = new Message { Chat = new Chat { Id = 456 } },
+ };
+ var update = new Update { CallbackQuery = callbackQuery };
+ var cancellationToken = CancellationToken.None;
+
+ // Act
+ await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken);
+
+ // Assert - убеждаемся, что бизнес-логика не вызывалась
+ this.paymentHandlerMock.Verify(
+ x => x.CreateDonationInvoice(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ ///
+ /// Tests that the "enter custom amount" callback correctly sets the user state.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAsyncEnterCustomAmountSetsUserState()
+ {
+ // Arrange
+ var callbackQuery = new CallbackQuery
+ {
+ Id = "test_query_id",
+ From = new User { Id = 123 },
+ Data = "enter_custom_amount",
+ Message = new Message { Chat = new Chat { Id = 456 } },
+ };
+ var update = new Update { CallbackQuery = callbackQuery };
+ var cancellationToken = CancellationToken.None;
+
+ // Act
+ await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken);
+
+ // Assert
+ this.userStateServiceMock.Verify(
+ x => x.SetWaitingForAmount(123, 456),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that predefined donation callbacks correctly create invoices with appropriate amounts.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAsyncPredefinedDonationCreatesInvoice()
+ {
+ // Arrange
+ var testCases = new[]
+ {
+ new { CallbackData = "donate_100", ExpectedAmount = 100 },
+ new { CallbackData = "donate_500", ExpectedAmount = 500 },
+ new { CallbackData = "donate_1000", ExpectedAmount = 1000 },
+ new { CallbackData = "donate_5000", ExpectedAmount = 5000 },
+ };
+
+ foreach (var testCase in testCases)
+ {
+ // Reset mocks for each test case
+ this.paymentHandlerMock.Invocations.Clear();
+
+ var callbackQuery = new CallbackQuery
+ {
+ Id = "test_query_id",
+ From = new User { Id = 123 },
+ Data = testCase.CallbackData,
+ Message = new Message { Chat = new Chat { Id = 456 } },
+ };
+ var update = new Update { CallbackQuery = callbackQuery };
+ var cancellationToken = CancellationToken.None;
+
+ // Act
+ await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken);
+
+ // Assert
+ this.paymentHandlerMock.Verify(
+ x => x.CreateDonationInvoice(this.botClientMock.Object, 456, 123, testCase.ExpectedAmount, cancellationToken),
+ Times.Once,
+ $"Failed for callback data: {testCase.CallbackData}");
+ }
+ }
+
+ ///
+ /// Tests that the "show stats" callback correctly triggers statistics display.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAsyncShowStatsCallsStatsCommand()
+ {
+ // Arrange
+ var callbackQuery = new CallbackQuery
+ {
+ Id = "test_query_id",
+ From = new User { Id = 123 },
+ Data = "show_stats",
+ Message = new Message { Chat = new Chat { Id = 456 } },
+ };
+ var update = new Update { CallbackQuery = callbackQuery };
+ var cancellationToken = CancellationToken.None;
+
+ // Act
+ await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken);
+
+ // Assert
+ this.commandHandlerMock.Verify(
+ x => x.HandleStatsCommand(this.botClientMock.Object, 456, cancellationToken),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that unknown callback data does not trigger any business logic.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAsyncUnknownCallbackDataDoesNotCallBusinessLogic()
+ {
+ // Arrange
+ var callbackQuery = new CallbackQuery
+ {
+ Id = "test_query_id",
+ From = new User { Id = 123 },
+ Data = "unknown_command",
+ Message = new Message { Chat = new Chat { Id = 456 } },
+ };
+ var update = new Update { CallbackQuery = callbackQuery };
+ var cancellationToken = CancellationToken.None;
+
+ // Act
+ await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken);
+
+ // Assert - убеждаемся, что бизнес-логика не вызывалась
+ this.paymentHandlerMock.Verify(
+ x => x.CreateDonationInvoice(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+
+ this.commandHandlerMock.Verify(
+ x => x.HandleStatsCommand(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+
+ this.userStateServiceMock.Verify(
+ x => x.SetWaitingForAmount(It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ ///
+ /// Tests that format exceptions during donation amount parsing are properly logged.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAsyncPaymentHandlerThrowsFormatExceptionLogsError()
+ {
+ // Arrange
+ var callbackQuery = new CallbackQuery
+ {
+ Id = "test_query_id",
+ From = new User { Id = 123 },
+ Data = "donate_invalid", // This will cause FormatException in parsing
+ Message = new Message { Chat = new Chat { Id = 456 } },
+ };
+ var update = new Update { CallbackQuery = callbackQuery };
+ var cancellationToken = CancellationToken.None;
+
+ // Act
+ await handler.HandleAsync(botClientMock.Object, update, cancellationToken);
+
+ // Assert - проверяем, что ошибка была залогирована
+ loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString() !.Contains("Failed to parse donation amount")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Never);
+ }
+
+ ///
+ /// Tests that general exceptions during invoice creation are properly logged.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAsyncGeneralExceptionInProcessCallsSafeAnswer()
+ {
+ // Arrange
+ var callbackQuery = new CallbackQuery
+ {
+ Id = "test_query_id",
+ From = new User { Id = 123 },
+ Data = "donate_100",
+ Message = new Message { Chat = new Chat { Id = 456 } },
+ };
+ var update = new Update { CallbackQuery = callbackQuery };
+ var cancellationToken = CancellationToken.None;
+
+ // Симулируем исключение в PaymentHandler
+ this.paymentHandlerMock
+ .Setup(x => x.CreateDonationInvoice(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new Exception("Test exception"));
+
+ // Act
+ await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken);
+
+ // Assert - проверяем, что ошибка была залогирована
+ this.loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString() !.Contains("Failed to create donation invoice")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ ///
+ /// Tests that exceptions during statistics display are properly logged.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task HandleAsyncCommandHandlerThrowsExceptionLogsError()
+ {
+ // Arrange
+ var callbackQuery = new CallbackQuery
+ {
+ Id = "test_query_id",
+ From = new User { Id = 123 },
+ Data = "show_stats",
+ Message = new Message { Chat = new Chat { Id = 456 } },
+ };
+ var update = new Update { CallbackQuery = callbackQuery };
+ var cancellationToken = CancellationToken.None;
+
+ // Симулируем исключение в CommandHandler
+ this.commandHandlerMock
+ .Setup(x => x.HandleStatsCommand(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new Exception("Test exception"));
+
+ // Act
+ await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken);
+
+ // Assert - проверяем, что ошибка была залогирована
+ this.loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString() !.Contains("Failed to show statistics")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+}
\ No newline at end of file
diff --git a/TestBot/Bot/Bot.Hadler/CommandHandlerTests.cs b/TestBot/Bot/Bot.Hadler/CommandHandlerTests.cs
new file mode 100755
index 0000000..34ec35d
--- /dev/null
+++ b/TestBot/Bot/Bot.Hadler/CommandHandlerTests.cs
@@ -0,0 +1,605 @@
+//
+// Copyright (c) PlaceholderCompany. All rights reserved.
+//
+
+using Bot.Handlers;
+using Bot.Services;
+using Data.Models;
+using Microsoft.Extensions.Logging;
+using Moq;
+using NUnit.Framework;
+using Services;
+using Telegram.Bot;
+using Telegram.Bot.Types;
+using Telegram.Bot.Types.Enums;
+using Telegram.Bot.Types.ReplyMarkups;
+
+namespace Bot.Tests.Handlers;
+
+///
+/// Unit tests for the class.
+///
+[TestFixture]
+public class CommandHandlerTests
+{
+ private Mock> loggerMock;
+ private Mock goalServiceMock;
+ private Mock keyboardServiceMock;
+ private Mock adminHandlerMock;
+ private Mock botClientMock;
+ private CommandHandler handler;
+
+ ///
+ /// Sets up the test environment before each test execution.
+ ///
+ [SetUp]
+ public void Setup()
+ {
+ this.loggerMock = new Mock>();
+ this.goalServiceMock = new Mock();
+
+ var keyboardServiceLoggerMock = new Mock>();
+ this.keyboardServiceMock = new Mock(keyboardServiceLoggerMock.Object);
+
+ var adminHandlerLoggerMock = new Mock>();
+ var adminStateServiceMock = new Mock(Mock.Of>());
+ this.adminHandlerMock = new Mock(
+ adminHandlerLoggerMock.Object,
+ Mock.Of(),
+ adminStateServiceMock.Object);
+
+ this.botClientMock = new Mock();
+
+ this.handler = new CommandHandler(
+ this.loggerMock.Object,
+ this.goalServiceMock.Object,
+ this.keyboardServiceMock.Object,
+ this.adminHandlerMock.Object);
+ }
+
+ ///