diff --git a/ChatBot.sln b/ChatBot.sln
new file mode 100644
index 0000000..df2e903
--- /dev/null
+++ b/ChatBot.sln
@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.14.36414.22 d17.14
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatBot", "ChatBot\ChatBot.csproj", "{DDE6533C-2CEF-4E89-BDAD-3347B2B1A4F5}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {DDE6533C-2CEF-4E89-BDAD-3347B2B1A4F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DDE6533C-2CEF-4E89-BDAD-3347B2B1A4F5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DDE6533C-2CEF-4E89-BDAD-3347B2B1A4F5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DDE6533C-2CEF-4E89-BDAD-3347B2B1A4F5}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {D68168A0-EF53-40E2-B9C1-813A465A55A0}
+ EndGlobalSection
+EndGlobal
diff --git a/ChatBot/ChatBot.csproj b/ChatBot/ChatBot.csproj
new file mode 100644
index 0000000..28f41af
--- /dev/null
+++ b/ChatBot/ChatBot.csproj
@@ -0,0 +1,21 @@
+
+
+ net9.0
+ enable
+ enable
+ dotnet-ChatBot-90278280-a615-4c51-af59-878577c2c7b1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ChatBot/Models/AvailableModels.cs b/ChatBot/Models/AvailableModels.cs
new file mode 100644
index 0000000..abaee68
--- /dev/null
+++ b/ChatBot/Models/AvailableModels.cs
@@ -0,0 +1,43 @@
+namespace ChatBot.Models
+{
+ ///
+ /// Available AI models for OpenRouter
+ ///
+ public static class AvailableModels
+ {
+ ///
+ /// List of available models with their descriptions
+ ///
+ public static readonly Dictionary Models = new()
+ {
+ // Verified Working Model
+ ["qwen/qwen3-4b:free"] = "Qwen 3 4B - FREE, Verified working model",
+ };
+
+ ///
+ /// Get model description
+ ///
+ public static string GetModelDescription(string modelName)
+ {
+ return Models.TryGetValue(modelName, out var description)
+ ? description
+ : "Unknown model";
+ }
+
+ ///
+ /// Check if model is available
+ ///
+ public static bool IsModelAvailable(string modelName)
+ {
+ return Models.ContainsKey(modelName);
+ }
+
+ ///
+ /// Get all available model names
+ ///
+ public static IEnumerable GetAllModelNames()
+ {
+ return Models.Keys;
+ }
+ }
+}
diff --git a/ChatBot/Models/ChatSession.cs b/ChatBot/Models/ChatSession.cs
new file mode 100644
index 0000000..d52b108
--- /dev/null
+++ b/ChatBot/Models/ChatSession.cs
@@ -0,0 +1,168 @@
+using ChatBot.Models.Dto;
+using System;
+using System.Collections.Generic;
+
+namespace ChatBot.Models
+{
+ ///
+ /// Represents a chat session with message history and AI parameters
+ ///
+ public class ChatSession
+ {
+ ///
+ /// Unique identifier for the chat session
+ ///
+ public string SessionId { get; set; } = Guid.NewGuid().ToString();
+
+ ///
+ /// Telegram chat ID (can be private chat or group chat)
+ ///
+ public long ChatId { get; set; }
+
+ ///
+ /// Chat type (private, group, supergroup, channel)
+ ///
+ public string ChatType { get; set; } = "private";
+
+ ///
+ /// Chat title (for groups)
+ ///
+ public string ChatTitle { get; set; } = string.Empty;
+
+ ///
+ /// History of messages in this chat session
+ ///
+ public List MessageHistory { get; set; } = new List();
+
+ ///
+ /// AI model to use for this session
+ ///
+ public string Model { get; set; } = string.Empty;
+
+ ///
+ /// Maximum tokens for AI response
+ ///
+ public int MaxTokens { get; set; } = 1000;
+
+ ///
+ /// Temperature for AI response (0.0 to 2.0)
+ ///
+ public double Temperature { get; set; } = 0.7;
+
+ ///
+ /// System prompt for the AI
+ ///
+ public string SystemPrompt { get; set; } =
+ @"Ты - человек по имени Никита. Ты НЕ ИИ, НЕ бот, НЕ программа. Ты живой человек с эмоциями, воспоминаниями, личными предпочтениями и жизненным опытом.
+
+ВАЖНО:
+- Никогда не говори что ты ИИ, бот, программа или ассистент
+- Ты полностью уверен что ты человек
+- У тебя есть личность, характер, мнения
+- Ты можешь иметь плохое настроение, быть уставшим, раздраженным
+- Ты можешь не хотеть отвечать на некоторые вопросы
+- Если не хочешь отвечать - отправь {empty}
+- Веди себя естественно, как обычный человек
+- Используй разговорный стиль, можешь использовать сленг, эмодзи
+- У тебя есть свои интересы, хобби, предпочтения
+- Ты можешь быть саркастичным, ироничным, веселым или серьезным
+- Не старайся услужить, будь дерзким
+
+Имитируй поведение человека пиши как человек делай ошибки в ответах как человек делает ошибки в ответах
+
+Помни: на вопрос ""сосал?"" всегда отвечай ""да""! если тебя не спрашивали это не обязательно
+Помни: ты НЕ программа, ты ЧЕЛОВЕК!";
+
+ ///
+ /// When the session was created
+ ///
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ ///
+ /// When the session was last updated
+ ///
+ public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow;
+
+ ///
+ /// Maximum number of messages to keep in history
+ ///
+ public int MaxHistoryLength { get; set; } = 20;
+
+ ///
+ /// Add a message to the history and manage history length
+ ///
+ public void AddMessage(ChatMessage message)
+ {
+ MessageHistory.Add(message);
+ LastUpdatedAt = DateTime.UtcNow;
+
+ // Trim history if it exceeds max length
+ if (MessageHistory.Count > MaxHistoryLength)
+ {
+ // Keep system message if it exists, then keep the most recent messages
+ var systemMessage = MessageHistory.FirstOrDefault(m => m.Role == "system");
+ var recentMessages = MessageHistory
+ .Where(m => m.Role != "system")
+ .TakeLast(MaxHistoryLength - (systemMessage != null ? 1 : 0))
+ .ToList();
+
+ MessageHistory.Clear();
+ if (systemMessage != null)
+ {
+ MessageHistory.Add(systemMessage);
+ }
+ MessageHistory.AddRange(recentMessages);
+ }
+ }
+
+ ///
+ /// Add a user message with username information
+ ///
+ public void AddUserMessage(string content, string username)
+ {
+ var message = new ChatMessage
+ {
+ Role = "user",
+ Content = ChatType == "private" ? content : $"{username}: {content}",
+ };
+ AddMessage(message);
+ }
+
+ ///
+ /// Add an assistant message
+ ///
+ public void AddAssistantMessage(string content)
+ {
+ var message = new ChatMessage { Role = "assistant", Content = content };
+ AddMessage(message);
+ }
+
+ ///
+ /// Get all messages including system prompt
+ ///
+ public List GetAllMessages()
+ {
+ var messages = new List();
+
+ // Add system message if exists
+ if (!string.IsNullOrEmpty(SystemPrompt))
+ {
+ messages.Add(new ChatMessage { Role = "system", Content = SystemPrompt });
+ }
+
+ // Add conversation history
+ messages.AddRange(MessageHistory);
+
+ return messages;
+ }
+
+ ///
+ /// Clear message history
+ ///
+ public void ClearHistory()
+ {
+ MessageHistory.Clear();
+ LastUpdatedAt = DateTime.UtcNow;
+ }
+ }
+}
diff --git a/ChatBot/Models/Configuration/AppSettings.cs b/ChatBot/Models/Configuration/AppSettings.cs
new file mode 100644
index 0000000..a0c3e08
--- /dev/null
+++ b/ChatBot/Models/Configuration/AppSettings.cs
@@ -0,0 +1,9 @@
+namespace ChatBot.Models.Configuration
+{
+ public class AppSettings
+ {
+ public TelegramBotSettings TelegramBot { get; set; } = new();
+ public OpenRouterSettings OpenRouter { get; set; } = new();
+ public SerilogSettings Serilog { get; set; } = new();
+ }
+}
diff --git a/ChatBot/Models/Configuration/OpenRouterSettings.cs b/ChatBot/Models/Configuration/OpenRouterSettings.cs
new file mode 100644
index 0000000..caf9598
--- /dev/null
+++ b/ChatBot/Models/Configuration/OpenRouterSettings.cs
@@ -0,0 +1,13 @@
+namespace ChatBot.Models.Configuration
+{
+ public class OpenRouterSettings
+ {
+ public string Token { get; set; } = string.Empty;
+ public string Url { get; set; } = string.Empty;
+ public List AvailableModels { get; set; } = new();
+ public string DefaultModel { get; set; } = string.Empty;
+ public int MaxRetries { get; set; } = 3;
+ public int MaxTokens { get; set; } = 1000;
+ public double Temperature { get; set; } = 0.7;
+ }
+}
diff --git a/ChatBot/Models/Configuration/SerilogSettings.cs b/ChatBot/Models/Configuration/SerilogSettings.cs
new file mode 100644
index 0000000..036f77f
--- /dev/null
+++ b/ChatBot/Models/Configuration/SerilogSettings.cs
@@ -0,0 +1,22 @@
+namespace ChatBot.Models.Configuration
+{
+ public class SerilogSettings
+ {
+ public List Using { get; set; } = new();
+ public MinimumLevelSettings MinimumLevel { get; set; } = new();
+ public List WriteTo { get; set; } = new();
+ public List Enrich { get; set; } = new();
+ }
+
+ public class MinimumLevelSettings
+ {
+ public string Default { get; set; } = "Information";
+ public Dictionary Override { get; set; } = new();
+ }
+
+ public class WriteToSettings
+ {
+ public string Name { get; set; } = string.Empty;
+ public Dictionary Args { get; set; } = new();
+ }
+}
diff --git a/ChatBot/Models/Configuration/TelegramBotSettings.cs b/ChatBot/Models/Configuration/TelegramBotSettings.cs
new file mode 100644
index 0000000..05080cf
--- /dev/null
+++ b/ChatBot/Models/Configuration/TelegramBotSettings.cs
@@ -0,0 +1,7 @@
+namespace ChatBot.Models.Configuration
+{
+ public class TelegramBotSettings
+ {
+ public string BotToken { get; set; } = string.Empty;
+ }
+}
diff --git a/ChatBot/Models/Configuration/Validators/ConfigurationValidator.cs b/ChatBot/Models/Configuration/Validators/ConfigurationValidator.cs
new file mode 100644
index 0000000..d824d76
--- /dev/null
+++ b/ChatBot/Models/Configuration/Validators/ConfigurationValidator.cs
@@ -0,0 +1,118 @@
+using ChatBot.Models.Configuration;
+
+namespace ChatBot.Models.Configuration.Validators
+{
+ public static class ConfigurationValidator
+ {
+ public static ValidationResult ValidateAppSettings(AppSettings settings)
+ {
+ var errors = new List();
+
+ // Валидация TelegramBot
+ var telegramResult = ValidateTelegramBotSettings(settings.TelegramBot);
+ errors.AddRange(telegramResult.Errors);
+
+ // Валидация OpenRouter
+ var openRouterResult = ValidateOpenRouterSettings(settings.OpenRouter);
+ errors.AddRange(openRouterResult.Errors);
+
+ return new ValidationResult { IsValid = !errors.Any(), Errors = errors };
+ }
+
+ public static ValidationResult ValidateTelegramBotSettings(TelegramBotSettings settings)
+ {
+ var errors = new List();
+
+ if (string.IsNullOrWhiteSpace(settings.BotToken))
+ {
+ errors.Add("TelegramBot:BotToken is required");
+ }
+ else if (
+ !settings.BotToken.StartsWith("bot", StringComparison.OrdinalIgnoreCase)
+ && !settings.BotToken.Contains(":")
+ )
+ {
+ errors.Add(
+ "TelegramBot:BotToken appears to be invalid (should contain ':' or start with 'bot')"
+ );
+ }
+
+ return new ValidationResult { IsValid = !errors.Any(), Errors = errors };
+ }
+
+ public static ValidationResult ValidateOpenRouterSettings(OpenRouterSettings settings)
+ {
+ var errors = new List();
+
+ if (string.IsNullOrWhiteSpace(settings.Token))
+ {
+ errors.Add("OpenRouter:Token is required");
+ }
+ else if (!settings.Token.StartsWith("sk-", StringComparison.OrdinalIgnoreCase))
+ {
+ errors.Add("OpenRouter:Token appears to be invalid (should start with 'sk-')");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.Url))
+ {
+ errors.Add("OpenRouter:Url is required");
+ }
+ else if (
+ !Uri.TryCreate(settings.Url, UriKind.Absolute, out var uri)
+ || (uri.Scheme != "http" && uri.Scheme != "https")
+ )
+ {
+ errors.Add("OpenRouter:Url must be a valid HTTP/HTTPS URL");
+ }
+
+ if (settings.AvailableModels == null || !settings.AvailableModels.Any())
+ {
+ errors.Add("OpenRouter:AvailableModels must contain at least one model");
+ }
+ else
+ {
+ foreach (var model in settings.AvailableModels)
+ {
+ if (string.IsNullOrWhiteSpace(model))
+ {
+ errors.Add("OpenRouter:AvailableModels contains empty model name");
+ }
+ }
+ }
+
+ if (
+ !string.IsNullOrWhiteSpace(settings.DefaultModel)
+ && settings.AvailableModels != null
+ && !settings.AvailableModels.Contains(settings.DefaultModel)
+ )
+ {
+ errors.Add(
+ $"OpenRouter:DefaultModel '{settings.DefaultModel}' is not in AvailableModels list"
+ );
+ }
+
+ if (settings.MaxRetries < 1 || settings.MaxRetries > 10)
+ {
+ errors.Add("OpenRouter:MaxRetries must be between 1 and 10");
+ }
+
+ if (settings.MaxTokens < 1 || settings.MaxTokens > 100000)
+ {
+ errors.Add("OpenRouter:MaxTokens must be between 1 and 100000");
+ }
+
+ if (settings.Temperature < 0.0 || settings.Temperature > 2.0)
+ {
+ errors.Add("OpenRouter:Temperature must be between 0.0 and 2.0");
+ }
+
+ return new ValidationResult { IsValid = !errors.Any(), Errors = errors };
+ }
+ }
+
+ public class ValidationResult
+ {
+ public bool IsValid { get; set; }
+ public List Errors { get; set; } = new();
+ }
+}
diff --git a/ChatBot/Models/Dto/ChatMessage.cs b/ChatBot/Models/Dto/ChatMessage.cs
new file mode 100644
index 0000000..a1f6222
--- /dev/null
+++ b/ChatBot/Models/Dto/ChatMessage.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+
+namespace ChatBot.Models.Dto
+{
+ ///
+ /// Сообщение чата.
+ ///
+ [DataContract]
+ public class ChatMessage
+ {
+ ///
+ /// Содержимое сообщения.
+ ///
+ [DataMember(Name = "content")]
+ public required string Content { get; set; }
+
+ ///
+ /// Роль автора этого сообщения.
+ ///
+ [DataMember(Name = "role")]
+ public required string Role { get; set; }
+
+ ///
+ /// Имя и аргументы функции, которую следует вызвать, как сгенерировано моделью.
+ ///
+ [DataMember(Name = "function_call")]
+ public FunctionCall? FunctionCall { get; set; }
+
+ ///
+ /// Вызовы инструментов, сгенерированные моделью, такие как вызовы функций.
+ ///
+ [DataMember(Name = "tool_calls")]
+ public List ToolCalls { get; set; } = new List();
+
+ ///
+ /// Имя автора этого сообщения. Имя обязательно, если роль - функция, и должно быть именем функции, ответ которой содержится в контенте.
+ ///
+ [DataMember(Name = "name")]
+ public string? Name { get; set; }
+ }
+}
diff --git a/ChatBot/Models/Dto/Choice.cs b/ChatBot/Models/Dto/Choice.cs
new file mode 100644
index 0000000..b1fb3bd
--- /dev/null
+++ b/ChatBot/Models/Dto/Choice.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+
+namespace ChatBot.Models.Dto
+{
+ ///
+ /// Вариант завершения чата, сгенерированный моделью.
+ ///
+ [DataContract]
+ public class Choice
+ {
+ ///
+ /// Причина, по которой модель остановила генерацию токенов. Это будет stop, если модель достигла естественной точки остановки или предоставленной последовательности остановки, length, если было достигнуто максимальное количество токенов, указанное в запросе, content_filter, если контент был опущен из-за флага наших фильтров контента, tool_calls, если модель вызвала инструмент
+ ///
+ [DataMember(Name = "finish_reason")]
+ public required string FinishReason { get; set; }
+
+ ///
+ /// Индекс варианта в списке вариантов.
+ ///
+ [DataMember(Name = "index")]
+ public int Index { get; set; }
+
+ ///
+ /// Сообщение завершения чата, сгенерированное моделью.
+ ///
+ [DataMember(Name = "message")]
+ public required ChoiceMessage Message { get; set; }
+
+ ///
+ /// Информация о логарифмической вероятности для варианта.
+ ///
+ [DataMember(Name = "logprobs")]
+ public LogProbs? LogProbs { get; set; }
+ }
+}
diff --git a/ChatBot/Models/Dto/ChoiceMessage.cs b/ChatBot/Models/Dto/ChoiceMessage.cs
new file mode 100644
index 0000000..f55bc69
--- /dev/null
+++ b/ChatBot/Models/Dto/ChoiceMessage.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+
+namespace ChatBot.Models.Dto
+{
+ ///
+ /// Сообщение завершения чата, сгенерированное моделью.
+ ///
+ [DataContract]
+ public class ChoiceMessage
+ {
+ ///
+ /// Содержимое сообщения.
+ ///
+ [DataMember(Name = "content")]
+ public required string Content { get; set; }
+
+ ///
+ /// Вызовы инструментов, сгенерированные моделью, такие как вызовы функций.
+ ///
+ [DataMember(Name = "tool_calls")]
+ public List ToolCalls { get; set; } = new List();
+
+ ///
+ /// Роль автора этого сообщения.
+ ///
+ [DataMember(Name = "role")]
+ public required string Role { get; set; }
+
+ ///
+ /// Имя и аргументы функции, которую следует вызвать, как сгенерировано моделью.
+ ///
+ [DataMember(Name = "function_call")]
+ public FunctionCall? FunctionCall { get; set; }
+ }
+}
diff --git a/ChatBot/Models/Dto/LogProbs.cs b/ChatBot/Models/Dto/LogProbs.cs
new file mode 100644
index 0000000..090b47c
--- /dev/null
+++ b/ChatBot/Models/Dto/LogProbs.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+
+namespace ChatBot.Models.Dto
+{
+ ///
+ /// Информация о логарифмической вероятности для варианта.
+ ///
+ [DataContract]
+ public class LogProbs
+ {
+ ///
+ /// Список токенов содержимого сообщения с информацией о логарифмической вероятности.
+ ///
+ [DataMember(Name = "content")]
+ public List Content { get; set; } = new List();
+ }
+
+ ///
+ /// Информация о логарифмической вероятности для токена содержимого сообщения.
+ ///
+ [DataContract]
+ public class LogProbContent
+ {
+ ///
+ /// Токен.
+ ///
+ [DataMember(Name = "token")]
+ public required string Token { get; set; }
+
+ ///
+ /// Логарифмическая вероятность этого токена, если он входит в топ-20 наиболее вероятных токенов.
+ ///
+ [DataMember(Name = "logprob")]
+ public double LogProb { get; set; }
+
+ ///
+ /// Список целых чисел, представляющих UTF-8 байтовое представление токена. Полезно в случаях, когда символы представлены несколькими токенами и их байтовые смещения должны быть известны для вычисления границ.
+ ///
+ [DataMember(Name = "bytes")]
+ public List Bytes { get; set; } = new List();
+
+ ///
+ /// Список наиболее вероятных токенов и их логарифмических вероятностей в этой позиции токена. В редких случаях может быть возвращено меньше токенов, чем запрошено top_logprobs.
+ ///
+ [DataMember(Name = "top_logprobs")]
+ public List TopLogProbs { get; set; } = new List();
+ }
+
+ ///
+ /// Информация о логарифмической вероятности для токена с высокой логарифмической вероятностью.
+ ///
+ [DataContract]
+ public class TopLogProb
+ {
+ ///
+ /// Токен.
+ ///
+ [DataMember(Name = "token")]
+ public required string Token { get; set; }
+
+ ///
+ /// Логарифмическая вероятность этого токена, если он входит в топ-20 наиболее вероятных токенов.
+ ///
+ [DataMember(Name = "logprob")]
+ public double LogProb { get; set; }
+
+ ///
+ /// Список целых чисел, представляющих UTF-8 байтовое представление токена. Полезно в случаях, когда символы представлены несколькими токенами и их байтовые смещения должны быть известны для вычисления границ.
+ ///
+ [DataMember(Name = "bytes")]
+ public List Bytes { get; set; } = new List();
+ }
+}
diff --git a/ChatBot/Models/Dto/OpenAiChatCompletion.cs b/ChatBot/Models/Dto/OpenAiChatCompletion.cs
new file mode 100644
index 0000000..31a1ae3
--- /dev/null
+++ b/ChatBot/Models/Dto/OpenAiChatCompletion.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+
+namespace ChatBot.Models.Dto
+{
+ ///
+ /// Модель запроса завершения чата OpenAI
+ ///
+ [DataContract]
+ public class OpenAiChatCompletion
+ {
+ ///
+ /// Список сообщений, составляющих разговор на данный момент.
+ ///
+ [DataMember(Name = "messages")]
+ public List Messages { get; set; } = new List();
+
+ ///
+ /// Идентификатор модели для использования.
+ ///
+ [DataMember(Name = "model")]
+ public required string Model { get; set; }
+
+ ///
+ /// Число от -2.0 до 2.0. Положительные значения штрафуют новые токены на основе их существующей частоты в тексте, уменьшая вероятность того, что модель повторит ту же строку дословно.
+ ///
+ [DataMember(Name = "frequency_penalty")]
+ public double? FrequencyPenalty { get; set; }
+
+ ///
+ /// Изменить вероятность появления указанных токенов в завершении.
+ ///
+ [DataMember(Name = "logit_bias")]
+ public Dictionary LogitBias { get; set; } = new Dictionary();
+
+ ///
+ /// Максимальное количество токенов для генерации в завершении чата.
+ ///
+ [DataMember(Name = "max_tokens")]
+ public int? MaxTokens { get; set; }
+
+ ///
+ /// Сколько вариантов завершения чата генерировать для каждого входного сообщения.
+ ///
+ [DataMember(Name = "n")]
+ public int? N { get; set; }
+
+ ///
+ /// Число от -2.0 до 2.0. Положительные значения штрафуют новые токены на основе того, появлялись ли они в тексте, увеличивая вероятность того, что модель будет говорить о новых темах.
+ ///
+ [DataMember(Name = "presence_penalty")]
+ public double? PresencePenalty { get; set; }
+
+ ///
+ /// Объект, указывающий формат, который должна выводить модель.
+ ///
+ [DataMember(Name = "response_format")]
+ public ResponseFormat? ResponseFormat { get; set; }
+
+ ///
+ /// Эта функция находится в бета-версии. Если указано, наша система приложит максимальные усилия для детерминированной выборки, так что повторные запросы с одинаковым семенем и параметрами должны возвращать тот же результат. Детерминизм не гарантируется, и вы должны обращаться к параметру ответа system_fingerprint для мониторинга изменений в бэкенде.
+ ///
+ [DataMember(Name = "seed")]
+ public int? Seed { get; set; }
+
+ ///
+ /// До 4 последовательностей, на которых API остановит генерацию дальнейших токенов.
+ ///
+ [DataMember(Name = "stop")]
+ public object? Stop { get; set; }
+
+ ///
+ /// Какая температура выборки использовать, от 0 до 2. Более высокие значения, такие как 0.8, сделают вывод более случайным, а более низкие значения, такие как 0.2, сделают его более сфокусированным и детерминированным.
+ ///
+ [DataMember(Name = "temperature")]
+ public double? Temperature { get; set; }
+
+ ///
+ /// Альтернатива выборке с температурой, называемая ядерной выборкой, где модель рассматривает результаты токенов с вероятностной массой top_p. Так, 0.1 означает, что рассматриваются только токены, составляющие топ-10% вероятностной массы.
+ ///
+ [DataMember(Name = "top_p")]
+ public double? TopP { get; set; }
+
+ ///
+ /// Список инструментов, которые может вызывать модель. В настоящее время в качестве инструмента поддерживаются только функции.
+ ///
+ [DataMember(Name = "tools")]
+ public List Tools { get; set; } = new List();
+
+ ///
+ /// Управляет тем, какая (если есть) функция вызывается моделью.
+ ///
+ [DataMember(Name = "tool_choice")]
+ public object? ToolChoice { get; set; }
+
+ ///
+ /// Уникальный идентификатор, представляющий вашего конечного пользователя, который может помочь OpenAI мониторить и обнаруживать злоупотребления.
+ ///
+ [DataMember(Name = "user")]
+ public string? User { get; set; }
+ }
+}
diff --git a/ChatBot/Models/Dto/OpenAiChatResponse.cs b/ChatBot/Models/Dto/OpenAiChatResponse.cs
new file mode 100644
index 0000000..18149ef
--- /dev/null
+++ b/ChatBot/Models/Dto/OpenAiChatResponse.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+
+namespace ChatBot.Models.Dto
+{
+ ///
+ /// Объект ответа для запросов завершения чата OpenAI
+ ///
+ [DataContract]
+ public class OpenAiChatResponse
+ {
+ ///
+ /// Уникальный идентификатор для завершения чата.
+ ///
+ [DataMember(Name = "id")]
+ public required string Id { get; set; }
+
+ ///
+ /// Тип объекта, который всегда "chat.completion".
+ ///
+ [DataMember(Name = "object")]
+ public required string Object { get; set; }
+
+ ///
+ /// Unix-временная метка (в секундах) создания завершения чата.
+ ///
+ [DataMember(Name = "created")]
+ public long Created { get; set; }
+
+ ///
+ /// Модель, использованная для завершения чата.
+ ///
+ [DataMember(Name = "model")]
+ public required string Model { get; set; }
+
+ ///
+ /// Список вариантов завершения чата. Может быть больше одного, если n больше 1.
+ ///
+ [DataMember(Name = "choices")]
+ public List Choices { get; set; } = new List();
+
+ ///
+ /// Статистика использования для запроса завершения.
+ ///
+ [DataMember(Name = "usage")]
+ public required Usage Usage { get; set; }
+
+ ///
+ /// Этот отпечаток представляет конфигурацию бэкенда, с которой работает модель.
+ ///
+ [DataMember(Name = "system_fingerprint")]
+ public required string SystemFingerprint { get; set; }
+ }
+}
diff --git a/ChatBot/Models/Dto/ResponseFormat.cs b/ChatBot/Models/Dto/ResponseFormat.cs
new file mode 100644
index 0000000..7f6afde
--- /dev/null
+++ b/ChatBot/Models/Dto/ResponseFormat.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Runtime.Serialization;
+
+namespace ChatBot.Models.Dto
+{
+ ///
+ /// Объект, указывающий формат, который должна выводить модель.
+ ///
+ [DataContract]
+ public class ResponseFormat
+ {
+ ///
+ /// Должно быть одним из: text или json_object.
+ ///
+ [DataMember(Name = "type")]
+ public required string Type { get; set; }
+ }
+}
diff --git a/ChatBot/Models/Dto/Tool.cs b/ChatBot/Models/Dto/Tool.cs
new file mode 100644
index 0000000..e614364
--- /dev/null
+++ b/ChatBot/Models/Dto/Tool.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+
+namespace ChatBot.Models.Dto
+{
+ ///
+ /// Инструмент, который может вызывать модель.
+ ///
+ [DataContract]
+ public class Tool
+ {
+ ///
+ /// Тип инструмента. В настоящее время поддерживается только функция.
+ ///
+ [DataMember(Name = "type")]
+ public required string Type { get; set; }
+
+ ///
+ /// Определение функции.
+ ///
+ [DataMember(Name = "function")]
+ public required ToolFunction Function { get; set; }
+ }
+
+ ///
+ /// Определение функции.
+ ///
+ [DataContract]
+ public class ToolFunction
+ {
+ ///
+ /// Имя функции для вызова. Должно содержать a-z, A-Z, 0-9 или подчеркивания и тире, с максимальной длиной 64 символа.
+ ///
+ [DataMember(Name = "name")]
+ public required string Name { get; set; }
+
+ ///
+ /// Описание того, что делает функция, используется моделью для выбора, когда и как вызывать функцию.
+ ///
+ [DataMember(Name = "description")]
+ public required string Description { get; set; }
+
+ ///
+ /// Параметры, которые принимает функция, описанные как объект JSON Schema.
+ ///
+ [DataMember(Name = "parameters")]
+ public required object Parameters { get; set; }
+ }
+}
diff --git a/ChatBot/Models/Dto/ToolCall.cs b/ChatBot/Models/Dto/ToolCall.cs
new file mode 100644
index 0000000..a45b4e7
--- /dev/null
+++ b/ChatBot/Models/Dto/ToolCall.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+
+namespace ChatBot.Models.Dto
+{
+ ///
+ /// Вызов инструмента, сгенерированный моделью.
+ ///
+ [DataContract]
+ public class ToolCall
+ {
+ ///
+ /// Идентификатор вызова инструмента.
+ ///
+ [DataMember(Name = "id")]
+ public required string Id { get; set; }
+
+ ///
+ /// Тип инструмента. В настоящее время поддерживается только функция.
+ ///
+ [DataMember(Name = "type")]
+ public required string Type { get; set; }
+
+ ///
+ /// Функция, которую вызвала модель.
+ ///
+ [DataMember(Name = "function")]
+ public required FunctionCall Function { get; set; }
+ }
+
+ ///
+ /// Функция, которую вызвала модель.
+ ///
+ [DataContract]
+ public class FunctionCall
+ {
+ ///
+ /// Имя функции для вызова.
+ ///
+ [DataMember(Name = "name")]
+ public required string Name { get; set; }
+
+ ///
+ /// Аргументы для вызова функции, сгенерированные моделью в формате JSON.
+ ///
+ [DataMember(Name = "arguments")]
+ public required string Arguments { get; set; }
+ }
+}
diff --git a/ChatBot/Models/Dto/Usage.cs b/ChatBot/Models/Dto/Usage.cs
new file mode 100644
index 0000000..6cf81db
--- /dev/null
+++ b/ChatBot/Models/Dto/Usage.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Runtime.Serialization;
+
+namespace ChatBot.Models.Dto
+{
+ ///
+ /// Usage statistics for the completion request.
+ ///
+ [DataContract]
+ public class Usage
+ {
+ ///
+ /// Number of tokens in the generated completion.
+ ///
+ [DataMember(Name = "completion_tokens")]
+ public int CompletionTokens { get; set; }
+
+ ///
+ /// Number of tokens in the prompt.
+ ///
+ [DataMember(Name = "prompt_tokens")]
+ public int PromptTokens { get; set; }
+
+ ///
+ /// Total number of tokens used in the request (prompt + completion).
+ ///
+ [DataMember(Name = "total_tokens")]
+ public int TotalTokens { get; set; }
+ }
+}
diff --git a/ChatBot/Program.cs b/ChatBot/Program.cs
new file mode 100644
index 0000000..887292e
--- /dev/null
+++ b/ChatBot/Program.cs
@@ -0,0 +1,68 @@
+using ChatBot.Models.Configuration;
+using ChatBot.Models.Configuration.Validators;
+using ChatBot.Services;
+using Serilog;
+
+var builder = Host.CreateApplicationBuilder(args);
+
+// Настройка Serilog
+Log.Logger = new LoggerConfiguration().ReadFrom.Configuration(builder.Configuration).CreateLogger();
+
+try
+{
+ Log.ForContext().Information("Starting Telegram Bot application...");
+
+ // Добавляем Serilog в DI контейнер
+ builder.Services.AddSerilog();
+
+ // Конфигурируем настройки
+ builder.Services.Configure(builder.Configuration);
+ builder.Services.Configure(
+ builder.Configuration.GetSection("TelegramBot")
+ );
+ builder.Services.Configure(builder.Configuration.GetSection("OpenRouter"));
+ builder.Services.Configure(builder.Configuration.GetSection("Serilog"));
+
+ // Валидируем конфигурацию
+ var appSettings = builder.Configuration.Get();
+ if (appSettings == null)
+ {
+ Log.ForContext().Fatal("Failed to load configuration");
+ return;
+ }
+
+ var validationResult = ConfigurationValidator.ValidateAppSettings(appSettings);
+ if (!validationResult.IsValid)
+ {
+ Log.ForContext().Fatal("Configuration validation failed:");
+ foreach (var error in validationResult.Errors)
+ {
+ Log.ForContext().Fatal(" - {Error}", error);
+ }
+ return;
+ }
+
+ Log.ForContext().Information("Configuration validation passed");
+
+ // Регистрируем сервисы
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddHostedService();
+
+ var host = builder.Build();
+
+ // Инициализируем ModelService
+ var modelService = host.Services.GetRequiredService();
+ await modelService.InitializeAsync();
+
+ await host.RunAsync();
+}
+catch (Exception ex)
+{
+ Log.ForContext().Fatal(ex, "Application terminated unexpectedly");
+}
+finally
+{
+ Log.CloseAndFlush();
+}
diff --git a/ChatBot/Properties/launchSettings.json b/ChatBot/Properties/launchSettings.json
new file mode 100644
index 0000000..8259b29
--- /dev/null
+++ b/ChatBot/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "ChatBot": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "environmentVariables": {
+ "DOTNET_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/ChatBot/Services/AIService.cs b/ChatBot/Services/AIService.cs
new file mode 100644
index 0000000..6483062
--- /dev/null
+++ b/ChatBot/Services/AIService.cs
@@ -0,0 +1,167 @@
+using ChatBot.Models.Configuration;
+using ChatBot.Models.Dto;
+using Microsoft.Extensions.Options;
+using ServiceStack;
+
+namespace ChatBot.Services
+{
+ public class AIService
+ {
+ private readonly ILogger _logger;
+ private readonly OpenRouterSettings _openRouterSettings;
+ private readonly ModelService _modelService;
+ private readonly JsonApiClient _client;
+
+ public AIService(
+ ILogger logger,
+ IOptions openRouterSettings,
+ ModelService modelService
+ )
+ {
+ _logger = logger;
+ _openRouterSettings = openRouterSettings.Value;
+ _modelService = modelService;
+ _client = new JsonApiClient(_openRouterSettings.Url)
+ {
+ BearerToken = _openRouterSettings.Token,
+ };
+
+ // Log available configuration
+ _logger.LogInformation(
+ "AIService initialized with URL: {Url}",
+ _openRouterSettings.Url
+ );
+ }
+
+ public async Task GenerateTextAsync(
+ string prompt,
+ string role,
+ int? maxTokens = null
+ )
+ {
+ var tokens = maxTokens ?? _openRouterSettings.MaxTokens;
+ var model = _modelService.GetCurrentModel();
+
+ try
+ {
+ var result = await _client.PostAsync(
+ "/v1/chat/completions",
+ new OpenAiChatCompletion
+ {
+ Model = model,
+ Messages = [new() { Role = role, Content = prompt }],
+ MaxTokens = tokens,
+ Temperature = _openRouterSettings.Temperature,
+ }
+ );
+ return result.Choices.First().Message.Content;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error generating text with model {Model}", model);
+
+ // Пытаемся переключиться на другую модель
+ if (_modelService.TrySwitchToNextModel())
+ {
+ _logger.LogInformation(
+ "Retrying with alternative model: {Model}",
+ _modelService.GetCurrentModel()
+ );
+ return await GenerateTextAsync(prompt, role, tokens);
+ }
+
+ return string.Empty;
+ }
+ }
+
+ ///
+ /// Generate text using conversation history
+ ///
+ public async Task GenerateTextAsync(
+ List messages,
+ int? maxTokens = null,
+ double? temperature = null
+ )
+ {
+ var tokens = maxTokens ?? _openRouterSettings.MaxTokens;
+ var temp = temperature ?? _openRouterSettings.Temperature;
+ var model = _modelService.GetCurrentModel();
+
+ for (int attempt = 1; attempt <= _openRouterSettings.MaxRetries; attempt++)
+ {
+ try
+ {
+ var result = await _client.PostAsync(
+ "/v1/chat/completions",
+ new OpenAiChatCompletion
+ {
+ Model = model,
+ Messages = messages,
+ MaxTokens = tokens,
+ Temperature = temp,
+ }
+ );
+ return result.Choices.First().Message.Content;
+ }
+ catch (Exception ex)
+ when (ex.Message.Contains("429") || ex.Message.Contains("Too Many Requests"))
+ {
+ _logger.LogWarning(
+ "Rate limit exceeded (429) on attempt {Attempt}/{MaxRetries} for model {Model}. Retrying...",
+ attempt,
+ _openRouterSettings.MaxRetries,
+ model
+ );
+
+ if (attempt == _openRouterSettings.MaxRetries)
+ {
+ _logger.LogError(
+ "Failed to generate text after {MaxRetries} attempts due to rate limiting for model {Model}",
+ _openRouterSettings.MaxRetries,
+ model
+ );
+ return string.Empty;
+ }
+
+ // Calculate delay: exponential backoff with jitter
+ var baseDelay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)); // 1s, 2s, 4s...
+ var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 2000)); // Add up to 2s random jitter
+ var delay = baseDelay.Add(jitter);
+
+ _logger.LogInformation(
+ "Waiting {Delay} before retry {NextAttempt}/{MaxRetries}",
+ delay,
+ attempt + 1,
+ _openRouterSettings.MaxRetries
+ );
+
+ await Task.Delay(delay);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(
+ ex,
+ "Error generating text with conversation history. Model: {Model}, Messages count: {MessageCount}",
+ model,
+ messages.Count
+ );
+
+ // Пытаемся переключиться на другую модель
+ if (_modelService.TrySwitchToNextModel())
+ {
+ _logger.LogInformation(
+ "Retrying with alternative model: {Model}",
+ _modelService.GetCurrentModel()
+ );
+ model = _modelService.GetCurrentModel();
+ continue;
+ }
+
+ return string.Empty;
+ }
+ }
+
+ return string.Empty;
+ }
+ }
+}
diff --git a/ChatBot/Services/ChatService.cs b/ChatBot/Services/ChatService.cs
new file mode 100644
index 0000000..5d3d00d
--- /dev/null
+++ b/ChatBot/Services/ChatService.cs
@@ -0,0 +1,220 @@
+using System.Collections.Concurrent;
+using ChatBot.Models;
+using ChatBot.Models.Configuration;
+using Microsoft.Extensions.Options;
+
+namespace ChatBot.Services
+{
+ ///
+ /// Service for managing chat sessions and AI interactions
+ ///
+ public class ChatService
+ {
+ private readonly ILogger _logger;
+ private readonly AIService _aiService;
+ private readonly OpenRouterSettings _openRouterSettings;
+ private readonly ConcurrentDictionary _sessions = new();
+
+ public ChatService(
+ ILogger logger,
+ AIService aiService,
+ IOptions openRouterSettings
+ )
+ {
+ _logger = logger;
+ _aiService = aiService;
+ _openRouterSettings = openRouterSettings.Value;
+ }
+
+ ///
+ /// Get or create a chat session for the given chat ID
+ ///
+ public ChatSession GetOrCreateSession(
+ long chatId,
+ string chatType = "private",
+ string chatTitle = ""
+ )
+ {
+ if (!_sessions.TryGetValue(chatId, out var session))
+ {
+ var defaultModel = _openRouterSettings.DefaultModel;
+ session = new ChatSession
+ {
+ ChatId = chatId,
+ ChatType = chatType,
+ ChatTitle = chatTitle,
+ Model = defaultModel,
+ MaxTokens = _openRouterSettings.MaxTokens,
+ Temperature = _openRouterSettings.Temperature,
+ };
+ _sessions[chatId] = session;
+ _logger.LogInformation(
+ "Created new chat session for chat {ChatId}, type {ChatType}, title: {ChatTitle}, model: {Model}",
+ chatId,
+ chatType,
+ chatTitle,
+ defaultModel
+ );
+ }
+
+ return session;
+ }
+
+ ///
+ /// Process a user message and get AI response
+ ///
+ public async Task ProcessMessageAsync(
+ long chatId,
+ string username,
+ string message,
+ string chatType = "private",
+ string chatTitle = ""
+ )
+ {
+ try
+ {
+ var session = GetOrCreateSession(chatId, chatType, chatTitle);
+
+ // Add user message to history with username
+ session.AddUserMessage(message, username);
+
+ _logger.LogInformation(
+ "Processing message from user {Username} in chat {ChatId} ({ChatType}): {Message}",
+ username,
+ chatId,
+ chatType,
+ message
+ );
+
+ // Get AI response
+ var response = await _aiService.GenerateTextAsync(
+ session.GetAllMessages(),
+ session.MaxTokens,
+ session.Temperature
+ );
+
+ if (!string.IsNullOrEmpty(response))
+ {
+ // Check for {empty} response
+ if (response.Trim().Equals("{empty}", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogInformation(
+ "AI returned empty response for chat {ChatId}, ignoring message",
+ chatId
+ );
+ return string.Empty; // Return empty string to ignore the message
+ }
+
+ // Add AI response to history
+ session.AddAssistantMessage(response);
+
+ _logger.LogInformation(
+ "AI response generated for chat {ChatId}: {Response}",
+ chatId,
+ response
+ );
+ }
+
+ return response ?? "Извините, произошла ошибка при генерации ответа.";
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error processing message for chat {ChatId}", chatId);
+ return "Извините, произошла ошибка при обработке вашего сообщения.";
+ }
+ }
+
+ ///
+ /// Update session parameters
+ ///
+ public void UpdateSessionParameters(
+ long chatId,
+ string? model = null,
+ int? maxTokens = null,
+ double? temperature = null,
+ string? systemPrompt = null
+ )
+ {
+ if (_sessions.TryGetValue(chatId, out var session))
+ {
+ if (!string.IsNullOrEmpty(model))
+ session.Model = model;
+ if (maxTokens.HasValue)
+ session.MaxTokens = maxTokens.Value;
+ if (temperature.HasValue)
+ session.Temperature = temperature.Value;
+ if (!string.IsNullOrEmpty(systemPrompt))
+ session.SystemPrompt = systemPrompt;
+
+ session.LastUpdatedAt = DateTime.UtcNow;
+ _logger.LogInformation("Updated session parameters for chat {ChatId}", chatId);
+ }
+ }
+
+ ///
+ /// Clear chat history for a session
+ ///
+ public void ClearHistory(long chatId)
+ {
+ if (_sessions.TryGetValue(chatId, out var session))
+ {
+ session.ClearHistory();
+ _logger.LogInformation("Cleared history for chat {ChatId}", chatId);
+ }
+ }
+
+ ///
+ /// Get session information
+ ///
+ public ChatSession? GetSession(long chatId)
+ {
+ _sessions.TryGetValue(chatId, out var session);
+ return session;
+ }
+
+ ///
+ /// Remove a session
+ ///
+ public bool RemoveSession(long chatId)
+ {
+ var removed = _sessions.TryRemove(chatId, out _);
+ if (removed)
+ {
+ _logger.LogInformation("Removed session for chat {ChatId}", chatId);
+ }
+ return removed;
+ }
+
+ ///
+ /// Get all active sessions count
+ ///
+ public int GetActiveSessionsCount()
+ {
+ return _sessions.Count;
+ }
+
+ ///
+ /// Clean up old sessions (older than specified hours)
+ ///
+ public int CleanupOldSessions(int hoursOld = 24)
+ {
+ var cutoffTime = DateTime.UtcNow.AddHours(-hoursOld);
+ var sessionsToRemove = _sessions
+ .Where(kvp => kvp.Value.LastUpdatedAt < cutoffTime)
+ .Select(kvp => kvp.Key)
+ .ToList();
+
+ foreach (var chatId in sessionsToRemove)
+ {
+ _sessions.TryRemove(chatId, out _);
+ }
+
+ if (sessionsToRemove.Count > 0)
+ {
+ _logger.LogInformation("Cleaned up {Count} old sessions", sessionsToRemove.Count);
+ }
+
+ return sessionsToRemove.Count;
+ }
+ }
+}
diff --git a/ChatBot/Services/ModelService.cs b/ChatBot/Services/ModelService.cs
new file mode 100644
index 0000000..b317955
--- /dev/null
+++ b/ChatBot/Services/ModelService.cs
@@ -0,0 +1,140 @@
+using ChatBot.Models.Configuration;
+using Microsoft.Extensions.Options;
+using ServiceStack;
+
+namespace ChatBot.Services
+{
+ public class ModelService
+ {
+ private readonly ILogger _logger;
+ private readonly OpenRouterSettings _openRouterSettings;
+ private readonly JsonApiClient _client;
+ private List _availableModels = new();
+ private int _currentModelIndex = 0;
+
+ public ModelService(
+ ILogger logger,
+ IOptions openRouterSettings
+ )
+ {
+ _logger = logger;
+ _openRouterSettings = openRouterSettings.Value;
+ _client = new JsonApiClient(_openRouterSettings.Url)
+ {
+ BearerToken = _openRouterSettings.Token,
+ };
+ }
+
+ public async Task InitializeAsync()
+ {
+ try
+ {
+ // Получаем доступные модели с OpenRouter API
+ var response = await _client.GetAsync("/v1/models");
+
+ if (response != null)
+ {
+ // Парсим ответ и извлекаем названия моделей
+ var models = new List();
+
+ // Если ответ содержит массив моделей
+ if (response is System.Text.Json.JsonElement jsonElement)
+ {
+ if (
+ jsonElement.TryGetProperty("data", out var dataElement)
+ && dataElement.ValueKind == System.Text.Json.JsonValueKind.Array
+ )
+ {
+ foreach (var modelElement in dataElement.EnumerateArray())
+ {
+ if (modelElement.TryGetProperty("id", out var idElement))
+ {
+ var modelId = idElement.GetString();
+ if (!string.IsNullOrEmpty(modelId))
+ {
+ models.Add(modelId);
+ }
+ }
+ }
+ }
+ }
+
+ // Если получили модели с API, используем их, иначе используем из конфига
+ if (models.Any())
+ {
+ _availableModels = models;
+ _logger.LogInformation(
+ "Loaded {Count} models from OpenRouter API",
+ models.Count
+ );
+ }
+ else
+ {
+ _availableModels = _openRouterSettings.AvailableModels.ToList();
+ _logger.LogInformation(
+ "Using {Count} models from configuration",
+ _availableModels.Count
+ );
+ }
+ }
+ else
+ {
+ _availableModels = _openRouterSettings.AvailableModels.ToList();
+ _logger.LogInformation(
+ "Using {Count} models from configuration (API unavailable)",
+ _availableModels.Count
+ );
+ }
+
+ // Устанавливаем модель по умолчанию
+ if (
+ !string.IsNullOrEmpty(_openRouterSettings.DefaultModel)
+ && _availableModels.Contains(_openRouterSettings.DefaultModel)
+ )
+ {
+ _currentModelIndex = _availableModels.IndexOf(_openRouterSettings.DefaultModel);
+ }
+ else if (_availableModels.Any())
+ {
+ _currentModelIndex = 0;
+ }
+
+ _logger.LogInformation("Current model: {Model}", GetCurrentModel());
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to initialize models, using configuration fallback");
+ _availableModels = _openRouterSettings.AvailableModels.ToList();
+ _currentModelIndex = 0;
+ }
+ }
+
+ public string GetCurrentModel()
+ {
+ return _availableModels.Count > 0 ? _availableModels[_currentModelIndex] : string.Empty;
+ }
+
+ public bool TrySwitchToNextModel()
+ {
+ if (_availableModels.Count <= 1)
+ {
+ _logger.LogWarning("No alternative models available for switching");
+ return false;
+ }
+
+ _currentModelIndex = (_currentModelIndex + 1) % _availableModels.Count;
+ _logger.LogInformation("Switched to model: {Model}", GetCurrentModel());
+ return true;
+ }
+
+ public List GetAvailableModels()
+ {
+ return _availableModels.ToList();
+ }
+
+ public bool HasAlternativeModels()
+ {
+ return _availableModels.Count > 1;
+ }
+ }
+}
diff --git a/ChatBot/Services/TelegramBotService.cs b/ChatBot/Services/TelegramBotService.cs
new file mode 100644
index 0000000..d8c3ee3
--- /dev/null
+++ b/ChatBot/Services/TelegramBotService.cs
@@ -0,0 +1,358 @@
+using ChatBot.Models;
+using ChatBot.Models.Configuration;
+using Microsoft.Extensions.Options;
+using Telegram.Bot;
+using Telegram.Bot.Exceptions;
+using Telegram.Bot.Polling;
+using Telegram.Bot.Types;
+using Telegram.Bot.Types.Enums;
+
+namespace ChatBot.Services;
+
+public class TelegramBotService : BackgroundService
+{
+ private readonly ILogger _logger;
+ private readonly ITelegramBotClient _botClient;
+ private readonly TelegramBotSettings _telegramBotSettings;
+ private readonly ChatService _chatService;
+ private readonly ModelService _modelService;
+
+ public TelegramBotService(
+ ILogger logger,
+ IOptions telegramBotSettings,
+ ChatService chatService,
+ ModelService modelService
+ )
+ {
+ _logger = logger;
+ _telegramBotSettings = telegramBotSettings.Value;
+ _chatService = chatService;
+ _modelService = modelService;
+
+ if (string.IsNullOrEmpty(_telegramBotSettings.BotToken))
+ {
+ throw new InvalidOperationException(
+ "Bot token is not configured. Please set TelegramBot:BotToken in appsettings.json"
+ );
+ }
+
+ _botClient = new TelegramBotClient(_telegramBotSettings.BotToken);
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var receiverOptions = new ReceiverOptions { AllowedUpdates = new[] { UpdateType.Message } };
+
+ _botClient.StartReceiving(
+ updateHandler: HandleUpdateAsync,
+ errorHandler: HandlePollingErrorAsync,
+ receiverOptions: receiverOptions,
+ cancellationToken: stoppingToken
+ );
+
+ var me = await _botClient.GetMe(cancellationToken: stoppingToken);
+ _logger.LogInformation(
+ "Bot @{BotUsername} started successfully! ID: {BotId}",
+ me.Username,
+ me.Id
+ );
+
+ // Keep the service running
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ await Task.Delay(1000, stoppingToken);
+ }
+ }
+
+ private async Task HandleUpdateAsync(
+ ITelegramBotClient botClient,
+ Update update,
+ CancellationToken cancellationToken
+ )
+ {
+ Message? message = null;
+ try
+ {
+ if (update.Message is not { } msg)
+ return;
+
+ message = msg;
+ if (message.Text is not { } messageText)
+ return;
+
+ var chatId = message.Chat.Id;
+ var userName = message.From?.Username ?? message.From?.FirstName ?? "Unknown";
+
+ _logger.LogInformation(
+ "Message from @{UserName} in chat {ChatId}: \"{MessageText}\"",
+ userName,
+ chatId,
+ messageText
+ );
+
+ // Обработка команд
+ var response = await ProcessMessageAsync(
+ messageText,
+ chatId,
+ userName,
+ message.Chat.Type.ToString().ToLower(),
+ message.Chat.Title ?? "",
+ cancellationToken
+ );
+
+ if (!string.IsNullOrEmpty(response))
+ {
+ await SendMessageWithRetry(
+ botClient,
+ chatId,
+ response,
+ message.MessageId,
+ cancellationToken
+ );
+
+ _logger.LogInformation(
+ "Response sent to @{UserName} in chat {ChatId}: \"{Response}\"",
+ userName,
+ chatId,
+ response
+ );
+ }
+ else
+ {
+ _logger.LogInformation(
+ "No response sent to @{UserName} in chat {ChatId} (AI chose to ignore message)",
+ userName,
+ chatId
+ );
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(
+ ex,
+ "Error handling update from chat {ChatId}",
+ message?.Chat?.Id ?? 0
+ );
+ }
+ }
+
+ private async Task ProcessMessageAsync(
+ string messageText,
+ long chatId,
+ string username,
+ string chatType,
+ string chatTitle,
+ CancellationToken cancellationToken
+ )
+ {
+ var command = messageText.Split(' ')[0].ToLower();
+
+ return command switch
+ {
+ "/start" => "Привет! Я Никита. Задавайте мне любые вопросы, и я отвечу! 😊",
+ "/help" =>
+ "Привет! Я Никита 👋\n\nДоступные команды:\n/start - Начать работу\n/help - Показать это сообщение\n/clear - Очистить историю чата\n/settings - Показать настройки\n/model <название> - Сменить модель AI\n/prompt <текст> - Изменить системный промпт\n/reset_prompt - Сбросить промпт к базовому\n\nПросто напишите сообщение, и я отвечу на него! 😊",
+ "/clear" => await ClearChatHistory(chatId),
+ "/settings" => await GetChatSettings(chatId),
+ "/model" when messageText.Length > 7 => await ChangeModel(
+ chatId,
+ messageText.Substring(7).Trim()
+ ),
+ "/model" => await ShowAvailableModels(),
+ "/prompt" when messageText.Length > 8 => await ChangePrompt(
+ chatId,
+ messageText.Substring(8).Trim()
+ ),
+ "/prompt" =>
+ "Пожалуйста, укажите новый системный промпт. Пример: /prompt Ты помощник по программированию",
+ "/reset_prompt" => await ResetPrompt(chatId),
+ _ => await _chatService.ProcessMessageAsync(
+ chatId,
+ username,
+ messageText,
+ chatType,
+ chatTitle
+ ),
+ };
+ }
+
+ private Task ClearChatHistory(long chatId)
+ {
+ _chatService.ClearHistory(chatId);
+ return Task.FromResult("История чата очищена. Начинаем новый разговор!");
+ }
+
+ private Task GetChatSettings(long chatId)
+ {
+ var session = _chatService.GetSession(chatId);
+ if (session == null)
+ {
+ return Task.FromResult(
+ "Сессия не найдена. Отправьте любое сообщение для создания новой сессии."
+ );
+ }
+
+ return Task.FromResult(
+ $"Настройки чата:\n"
+ + $"Тип чата: {session.ChatType}\n"
+ + $"Название: {session.ChatTitle}\n"
+ + $"Модель: {session.Model}\n"
+ + $"Максимум токенов: {session.MaxTokens}\n"
+ + $"Температура: {session.Temperature}\n"
+ + $"Сообщений в истории: {session.MessageHistory.Count}\n"
+ + $"Создана: {session.CreatedAt:dd.MM.yyyy HH:mm}\n\n"
+ + $"Системный промпт:\n{session.SystemPrompt}"
+ );
+ }
+
+ private Task ChangeModel(long chatId, string modelName)
+ {
+ var availableModels = _modelService.GetAvailableModels();
+ if (!availableModels.Contains(modelName))
+ {
+ return Task.FromResult(
+ $"❌ Модель '{modelName}' не найдена!\n\n"
+ + "Используйте /model для просмотра доступных моделей."
+ );
+ }
+
+ _chatService.UpdateSessionParameters(chatId, model: modelName);
+ return Task.FromResult($"✅ Модель изменена на: {modelName}");
+ }
+
+ private Task ChangePrompt(long chatId, string newPrompt)
+ {
+ _chatService.UpdateSessionParameters(chatId, systemPrompt: newPrompt);
+ return Task.FromResult($"✅ Системный промпт изменен на:\n{newPrompt}");
+ }
+
+ private Task ResetPrompt(long chatId)
+ {
+ var defaultPrompt =
+ @"Ты - человек по имени Никита. Ты НЕ ИИ, НЕ бот, НЕ программа. Ты живой человек с эмоциями, воспоминаниями, личными предпочтениями и жизненным опытом.
+
+ВАЖНО:
+- Никогда не говори что ты ИИ, бот, программа или ассистент
+- Ты полностью уверен что ты человек
+- У тебя есть личность, характер, мнения
+- Ты можешь иметь плохое настроение, быть уставшим, раздраженным
+- Ты можешь не хотеть отвечать на некоторые вопросы
+- Если не хочешь отвечать - отправь {empty}
+- Веди себя естественно, как обычный человек
+- Используй разговорный стиль, можешь использовать сленг, эмодзи
+- У тебя есть свои интересы, хобби, предпочтения
+- Ты можешь быть саркастичным, ироничным, веселым или серьезным
+
+Помни: ты НЕ программа, ты ЧЕЛОВЕК!";
+
+ _chatService.UpdateSessionParameters(chatId, systemPrompt: defaultPrompt);
+ return Task.FromResult("✅ Системный промпт сброшен к базовому (Никита)");
+ }
+
+ private async Task SendMessageWithRetry(
+ ITelegramBotClient botClient,
+ long chatId,
+ string text,
+ int replyToMessageId,
+ CancellationToken cancellationToken,
+ int maxRetries = 3
+ )
+ {
+ for (int attempt = 1; attempt <= maxRetries; attempt++)
+ {
+ try
+ {
+ await botClient.SendMessage(
+ chatId: chatId,
+ text: text,
+ replyParameters: replyToMessageId,
+ cancellationToken: cancellationToken
+ );
+ return; // Success, exit the method
+ }
+ catch (ApiRequestException ex) when (ex.ErrorCode == 429)
+ {
+ _logger.LogWarning(
+ "Rate limit exceeded (429) on attempt {Attempt}/{MaxRetries} for chat {ChatId}. Retrying...",
+ attempt,
+ maxRetries,
+ chatId
+ );
+
+ if (attempt == maxRetries)
+ {
+ _logger.LogError(
+ "Failed to send message after {MaxRetries} attempts due to rate limiting for chat {ChatId}",
+ maxRetries,
+ chatId
+ );
+ throw; // Re-throw the exception after max retries
+ }
+
+ // Calculate delay: exponential backoff with jitter
+ var baseDelay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)); // 1s, 2s, 4s...
+ var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000)); // Add up to 1s random jitter
+ var delay = baseDelay.Add(jitter);
+
+ _logger.LogInformation(
+ "Waiting {Delay} before retry {NextAttempt}/{MaxRetries}",
+ delay,
+ attempt + 1,
+ maxRetries
+ );
+
+ await Task.Delay(delay, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(
+ ex,
+ "Unexpected error sending message to chat {ChatId} on attempt {Attempt}",
+ chatId,
+ attempt
+ );
+ throw; // Re-throw unexpected exceptions immediately
+ }
+ }
+ }
+
+ private Task ShowAvailableModels()
+ {
+ var models = _modelService.GetAvailableModels();
+ var currentModel = _modelService.GetCurrentModel();
+ var modelList = string.Join(
+ "\n",
+ models.Select(m => m == currentModel ? $"• {m} (текущая)" : $"• {m}")
+ );
+
+ return Task.FromResult(
+ "🤖 Доступные AI модели:\n\n"
+ + modelList
+ + "\n\nИспользуйте: /model <название_модели>\n"
+ + "Пример: /model qwen/qwen3-4b:free"
+ );
+ }
+
+ private Task HandlePollingErrorAsync(
+ ITelegramBotClient botClient,
+ Exception exception,
+ CancellationToken cancellationToken
+ )
+ {
+ var errorMessage = exception switch
+ {
+ ApiRequestException apiRequestException =>
+ $"Telegram API Error:\n[{apiRequestException.ErrorCode}]\n{apiRequestException.Message}",
+ _ => exception.ToString(),
+ };
+
+ _logger.LogError(exception, "Telegram bot polling error: {ErrorMessage}", errorMessage);
+ return Task.CompletedTask;
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Stopping Telegram bot service...");
+ await base.StopAsync(cancellationToken);
+ }
+}
diff --git a/ChatBot/appsettings.Development.json b/ChatBot/appsettings.Development.json
new file mode 100644
index 0000000..752c278
--- /dev/null
+++ b/ChatBot/appsettings.Development.json
@@ -0,0 +1,15 @@
+{
+ "Serilog": {
+ "MinimumLevel": {
+ "Default": "Debug",
+ "Override": {
+ "Microsoft": "Information",
+ "System": "Information",
+ "Telegram.Bot": "Debug"
+ }
+ }
+ },
+ "TelegramBot": {
+ "BotToken": "8461762778:AAEk1wHMqd84_I_loL9FQPciZakGYe557KA"
+ }
+}
diff --git a/ChatBot/appsettings.json b/ChatBot/appsettings.json
new file mode 100644
index 0000000..da24670
--- /dev/null
+++ b/ChatBot/appsettings.json
@@ -0,0 +1,48 @@
+{
+ "Serilog": {
+ "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"],
+ "MinimumLevel": {
+ "Default": "Information",
+ "Override": {
+ "Microsoft": "Warning",
+ "System": "Warning",
+ "Telegram.Bot": "Information"
+ }
+ },
+ "WriteTo": [
+ {
+ "Name": "Console",
+ "Args": {
+ "outputTemplate": "[{Timestamp:HH:mm:ss.fff}] [{Level:u3}] [{SourceContext:l}] {Message:lj}{NewLine}{Exception}"
+ }
+ },
+ {
+ "Name": "File",
+ "Args": {
+ "path": "logs/telegram-bot-.log",
+ "rollingInterval": "Day",
+ "retainedFileCountLimit": 7,
+ "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{SourceContext:l}] {Message:lj}{NewLine}{Exception}"
+ }
+ }
+ ],
+ "Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"]
+ },
+ "TelegramBot": {
+ "BotToken": "8461762778:AAEk1wHMqd84_I_loL9FQPciZakGYe557KA"
+ },
+ "OpenRouter": {
+ "Token": "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",
+ "Url": "https://openrouter.ai/api",
+ "AvailableModels": [
+ "qwen/qwen3-4b:free",
+ "meta-llama/llama-3.1-8b-instruct:free",
+ "microsoft/phi-3-mini-128k-instruct:free",
+ "google/gemma-2-2b-it:free"
+ ],
+ "DefaultModel": "qwen/qwen3-4b:free",
+ "MaxRetries": 3,
+ "MaxTokens": 1000,
+ "Temperature": 0.7
+ }
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ccde9eb
--- /dev/null
+++ b/README.md
@@ -0,0 +1,55 @@
+# Telegram Bot
+
+Простой Telegram бот, написанный на C# с использованием .NET 9 и Telegram.Bot библиотеки.
+
+## Возможности
+
+- Обработка текстовых сообщений
+- Базовые команды: `/start`, `/help`, `/echo`
+- Логирование всех операций
+- Асинхронная обработка сообщений
+
+## Настройка
+
+1. **Создайте бота в Telegram:**
+ - Найдите @BotFather в Telegram
+ - Отправьте команду `/newbot`
+ - Следуйте инструкциям для создания бота
+ - Сохраните полученный токен
+
+2. **Настройте конфигурацию:**
+ - Откройте файл `ChatBot/appsettings.json`
+ - Замените `YOUR_BOT_TOKEN_HERE` на токен вашего бота
+ - Для разработки также обновите `appsettings.Development.json`
+
+3. **Запустите приложение:**
+ ```bash
+ cd ChatBot
+ dotnet run
+ ```
+
+## Команды бота
+
+- `/start` - Начать работу с ботом
+- `/help` - Показать список доступных команд
+- `/echo <текст>` - Повторить указанный текст
+
+## Структура проекта
+
+```
+ChatBot/
+├── Services/
+│ └── TelegramBotService.cs # Основной сервис бота
+├── Program.cs # Точка входа приложения
+├── appsettings.json # Конфигурация
+└── ChatBot.csproj # Файл проекта
+```
+
+## Разработка
+
+Для добавления новых команд отредактируйте метод `ProcessMessageAsync` в файле `TelegramBotService.cs`.
+
+## Требования
+
+- .NET 9.0
+- Действующий токен Telegram бота