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 бота