From 5027d16f7810e5ca1f383c4fe9fcb37cf609d306 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 17 Oct 2025 00:54:31 +0300 Subject: [PATCH] add db --- ChatBot/.gitea/workflows/build.yml | 89 +++++++++ ChatBot/ChatBot.csproj | 6 + ChatBot/Data/ChatBotDbContext.cs | 78 ++++++++ .../Data/Interfaces/IChatSessionRepository.cs | 74 +++++++ .../Repositories/ChatSessionRepository.cs | 171 ++++++++++++++++ ChatBot/HISTORY_COMPRESSION.md | 86 -------- .../20251016214154_InitialCreate.Designer.cs | 149 ++++++++++++++ .../20251016214154_InitialCreate.cs | 95 +++++++++ .../ChatBotDbContextModelSnapshot.cs | 146 ++++++++++++++ .../Models/Configuration/DatabaseSettings.cs | 23 +++ .../Validators/DatabaseSettingsValidator.cs | 40 ++++ ChatBot/Models/Entities/ChatMessageEntity.cs | 61 ++++++ ChatBot/Models/Entities/ChatSessionEntity.cs | 82 ++++++++ ChatBot/Program.cs | 71 +++++-- ChatBot/Services/ChatService.cs | 18 +- .../Services/DatabaseInitializationService.cs | 74 +++++++ ChatBot/Services/DatabaseSessionStorage.cs | 189 ++++++++++++++++++ ChatBot/Services/InMemorySessionStorage.cs | 7 + .../Services/Interfaces/ISessionStorage.cs | 5 + .../Telegram/Commands/ClearCommand.cs | 6 +- ChatBot/Services/TelegramBotHostedService.cs | 46 +++++ ChatBot/appsettings.json | 7 + 22 files changed, 1418 insertions(+), 105 deletions(-) create mode 100644 ChatBot/.gitea/workflows/build.yml create mode 100644 ChatBot/Data/ChatBotDbContext.cs create mode 100644 ChatBot/Data/Interfaces/IChatSessionRepository.cs create mode 100644 ChatBot/Data/Repositories/ChatSessionRepository.cs delete mode 100644 ChatBot/HISTORY_COMPRESSION.md create mode 100644 ChatBot/Migrations/20251016214154_InitialCreate.Designer.cs create mode 100644 ChatBot/Migrations/20251016214154_InitialCreate.cs create mode 100644 ChatBot/Migrations/ChatBotDbContextModelSnapshot.cs create mode 100644 ChatBot/Models/Configuration/DatabaseSettings.cs create mode 100644 ChatBot/Models/Configuration/Validators/DatabaseSettingsValidator.cs create mode 100644 ChatBot/Models/Entities/ChatMessageEntity.cs create mode 100644 ChatBot/Models/Entities/ChatSessionEntity.cs create mode 100644 ChatBot/Services/DatabaseInitializationService.cs create mode 100644 ChatBot/Services/DatabaseSessionStorage.cs create mode 100644 ChatBot/Services/TelegramBotHostedService.cs diff --git a/ChatBot/.gitea/workflows/build.yml b/ChatBot/.gitea/workflows/build.yml new file mode 100644 index 0000000..e3e2cee --- /dev/null +++ b/ChatBot/.gitea/workflows/build.yml @@ -0,0 +1,89 @@ +name: ChatBot CI/CD +on: + push: + branches: + - master + - main + pull_request: + types: [opened, synchronize, reopened] +jobs: + build: + name: Build and Test + runs-on: windows-latest + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: chatbot_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: + - name: Set up JDK 17 (for SonarQube) + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'zulu' + + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Cache SonarQube Cloud packages + uses: actions/cache@v4 + with: + path: ~\sonar\cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Cache SonarQube Cloud scanner + id: cache-sonar-scanner + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}\scanner + key: ${{ runner.os }}-sonar-scanner + restore-keys: ${{ runner.os }}-sonar-scanner + + - name: Install SonarQube Cloud scanner + if: steps.cache-sonar-scanner.outputs.cache-hit != 'true' + shell: powershell + run: | + New-Item -Path ${{ runner.temp }}\scanner -ItemType Directory + dotnet tool update dotnet-sonarscanner --tool-path ${{ runner.temp }}\scanner + + - name: Restore dependencies + run: dotnet restore + + - name: Build project + run: dotnet build --no-restore --configuration Release + + - name: Run tests + run: dotnet test --no-build --configuration Release --verbosity normal + env: + ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database=chatbot_test;Username=postgres;Password=postgres" + + - name: Run database migrations + run: dotnet ef database update --context ChatBotDbContext --no-build + env: + ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database=chatbot_test;Username=postgres;Password=postgres" + + - name: Code analysis with SonarQube + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + shell: powershell + run: | + ${{ runner.temp }}\scanner\dotnet-sonarscanner begin /k:"mrleo1nid_chatbot" /o:"mrleo1nid" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" + dotnet build --configuration Release + ${{ runner.temp }}\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \ No newline at end of file diff --git a/ChatBot/ChatBot.csproj b/ChatBot/ChatBot.csproj index 9c59d25..59a997a 100644 --- a/ChatBot/ChatBot.csproj +++ b/ChatBot/ChatBot.csproj @@ -18,6 +18,12 @@ + + + + + + diff --git a/ChatBot/Data/ChatBotDbContext.cs b/ChatBot/Data/ChatBotDbContext.cs new file mode 100644 index 0000000..8098dbd --- /dev/null +++ b/ChatBot/Data/ChatBotDbContext.cs @@ -0,0 +1,78 @@ +using ChatBot.Models.Entities; +using Microsoft.EntityFrameworkCore; + +namespace ChatBot.Data +{ + /// + /// DbContext for ChatBot application + /// + public class ChatBotDbContext : DbContext + { + public ChatBotDbContext(DbContextOptions options) + : base(options) { } + + /// + /// Chat sessions table + /// + public DbSet ChatSessions { get; set; } + + /// + /// Chat messages table + /// + public DbSet ChatMessages { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Configure ChatSessionEntity + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.SessionId).IsRequired().HasMaxLength(50); + entity.Property(e => e.ChatId).IsRequired(); + entity.Property(e => e.ChatType).IsRequired().HasMaxLength(20); + entity.Property(e => e.ChatTitle).HasMaxLength(200); + entity.Property(e => e.Model).HasMaxLength(100); + entity.Property(e => e.CreatedAt).IsRequired(); + entity.Property(e => e.LastUpdatedAt).IsRequired(); + + // Unique constraint on SessionId + entity.HasIndex(e => e.SessionId).IsUnique(); + + // Index on ChatId for fast lookups + entity.HasIndex(e => e.ChatId); + + // One-to-many relationship with messages + entity + .HasMany(e => e.Messages) + .WithOne(e => e.Session) + .HasForeignKey(e => e.SessionId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // Configure ChatMessageEntity + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.SessionId).IsRequired(); + entity.Property(e => e.Content).IsRequired().HasMaxLength(10000); + entity.Property(e => e.Role).IsRequired().HasMaxLength(20); + entity.Property(e => e.CreatedAt).IsRequired(); + + // Index on SessionId for fast lookups + entity.HasIndex(e => e.SessionId); + + // Index on CreatedAt for ordering + entity.HasIndex(e => e.CreatedAt); + + // Composite index for efficient querying by session and order + entity.HasIndex(e => new { e.SessionId, e.MessageOrder }); + }); + + // Configure table names to use snake_case + modelBuilder.Entity().ToTable("chat_sessions"); + modelBuilder.Entity().ToTable("chat_messages"); + } + } +} diff --git a/ChatBot/Data/Interfaces/IChatSessionRepository.cs b/ChatBot/Data/Interfaces/IChatSessionRepository.cs new file mode 100644 index 0000000..56c8301 --- /dev/null +++ b/ChatBot/Data/Interfaces/IChatSessionRepository.cs @@ -0,0 +1,74 @@ +using ChatBot.Models.Entities; + +namespace ChatBot.Data.Interfaces +{ + /// + /// Repository interface for chat session operations + /// + public interface IChatSessionRepository + { + /// + /// Get or create a chat session by chat ID + /// + Task GetOrCreateAsync( + long chatId, + string chatType = "private", + string chatTitle = "" + ); + + /// + /// Get a chat session by chat ID + /// + Task GetByChatIdAsync(long chatId); + + /// + /// Get a chat session by session ID + /// + Task GetBySessionIdAsync(string sessionId); + + /// + /// Update a chat session + /// + Task UpdateAsync(ChatSessionEntity session); + + /// + /// Delete a chat session + /// + Task DeleteAsync(long chatId); + + /// + /// Get all messages for a session + /// + Task> GetMessagesAsync(int sessionId); + + /// + /// Add a message to a session + /// + Task AddMessageAsync( + int sessionId, + string content, + string role, + int messageOrder + ); + + /// + /// Clear all messages for a session + /// + Task ClearMessagesAsync(int sessionId); + + /// + /// Get count of active sessions + /// + Task GetActiveSessionsCountAsync(); + + /// + /// Clean up old sessions + /// + Task CleanupOldSessionsAsync(int hoursOld = 24); + + /// + /// Get sessions that need cleanup + /// + Task> GetSessionsForCleanupAsync(int hoursOld = 24); + } +} diff --git a/ChatBot/Data/Repositories/ChatSessionRepository.cs b/ChatBot/Data/Repositories/ChatSessionRepository.cs new file mode 100644 index 0000000..c3e129f --- /dev/null +++ b/ChatBot/Data/Repositories/ChatSessionRepository.cs @@ -0,0 +1,171 @@ +using ChatBot.Data.Interfaces; +using ChatBot.Models.Entities; +using Microsoft.EntityFrameworkCore; + +namespace ChatBot.Data.Repositories +{ + /// + /// Repository implementation for chat session operations + /// + public class ChatSessionRepository : IChatSessionRepository + { + private readonly ChatBotDbContext _context; + private readonly ILogger _logger; + + public ChatSessionRepository( + ChatBotDbContext context, + ILogger logger + ) + { + _context = context; + _logger = logger; + } + + public async Task GetOrCreateAsync( + long chatId, + string chatType = "private", + string chatTitle = "" + ) + { + var session = await GetByChatIdAsync(chatId); + + if (session == null) + { + session = new ChatSessionEntity + { + SessionId = Guid.NewGuid().ToString(), + ChatId = chatId, + ChatType = chatType, + ChatTitle = chatTitle, + Model = string.Empty, // Will be set by ModelService + CreatedAt = DateTime.UtcNow, + LastUpdatedAt = DateTime.UtcNow, + MaxHistoryLength = 30, + }; + + _context.ChatSessions.Add(session); + await _context.SaveChangesAsync(); + + _logger.LogInformation( + "Created new chat session for chat {ChatId}, type: {ChatType}, title: {ChatTitle}", + chatId, + chatType, + chatTitle + ); + } + + return session; + } + + public async Task GetByChatIdAsync(long chatId) + { + return await _context + .ChatSessions.Include(s => s.Messages.OrderBy(m => m.MessageOrder)) + .FirstOrDefaultAsync(s => s.ChatId == chatId); + } + + public async Task GetBySessionIdAsync(string sessionId) + { + return await _context + .ChatSessions.Include(s => s.Messages.OrderBy(m => m.MessageOrder)) + .FirstOrDefaultAsync(s => s.SessionId == sessionId); + } + + public async Task UpdateAsync(ChatSessionEntity session) + { + session.LastUpdatedAt = DateTime.UtcNow; + _context.ChatSessions.Update(session); + await _context.SaveChangesAsync(); + return session; + } + + public async Task DeleteAsync(long chatId) + { + var session = await _context.ChatSessions.FirstOrDefaultAsync(s => s.ChatId == chatId); + if (session == null) + return false; + + _context.ChatSessions.Remove(session); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Deleted session for chat {ChatId}", chatId); + return true; + } + + public async Task> GetMessagesAsync(int sessionId) + { + return await _context + .ChatMessages.Where(m => m.SessionId == sessionId) + .OrderBy(m => m.MessageOrder) + .ToListAsync(); + } + + public async Task AddMessageAsync( + int sessionId, + string content, + string role, + int messageOrder + ) + { + var message = new ChatMessageEntity + { + SessionId = sessionId, + Content = content, + Role = role, + MessageOrder = messageOrder, + CreatedAt = DateTime.UtcNow, + }; + + _context.ChatMessages.Add(message); + await _context.SaveChangesAsync(); + return message; + } + + public async Task ClearMessagesAsync(int sessionId) + { + var messages = await _context + .ChatMessages.Where(m => m.SessionId == sessionId) + .ToListAsync(); + + _context.ChatMessages.RemoveRange(messages); + await _context.SaveChangesAsync(); + + _logger.LogInformation( + "Cleared {Count} messages for session {SessionId}", + messages.Count, + sessionId + ); + } + + public async Task GetActiveSessionsCountAsync() + { + return await _context.ChatSessions.CountAsync(); + } + + public async Task CleanupOldSessionsAsync(int hoursOld = 24) + { + var cutoffTime = DateTime.UtcNow.AddHours(-hoursOld); + var sessionsToRemove = await _context + .ChatSessions.Where(s => s.LastUpdatedAt < cutoffTime) + .ToListAsync(); + + if (sessionsToRemove.Any()) + { + _context.ChatSessions.RemoveRange(sessionsToRemove); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Cleaned up {Count} old sessions", sessionsToRemove.Count); + } + + return sessionsToRemove.Count; + } + + public async Task> GetSessionsForCleanupAsync(int hoursOld = 24) + { + var cutoffTime = DateTime.UtcNow.AddHours(-hoursOld); + return await _context + .ChatSessions.Where(s => s.LastUpdatedAt < cutoffTime) + .ToListAsync(); + } + } +} diff --git a/ChatBot/HISTORY_COMPRESSION.md b/ChatBot/HISTORY_COMPRESSION.md deleted file mode 100644 index 5dc8d73..0000000 --- a/ChatBot/HISTORY_COMPRESSION.md +++ /dev/null @@ -1,86 +0,0 @@ -# Система постепенного сжатия истории сообщений - -## Обзор - -Реализована система постепенного сжатия истории сообщений для оптимизации использования памяти и улучшения производительности чат-бота. Система автоматически сжимает старые сообщения, сохраняя при этом важную информацию. - -## Основные возможности - -### 1. Автоматическое сжатие -- Сжатие активируется при превышении порогового количества сообщений -- Старые сообщения группируются и сжимаются в краткие резюме -- Системные сообщения всегда сохраняются -- Последние сообщения остаются без изменений - -### 2. Умная суммаризация -- Использование ИИ для создания кратких резюме старых сообщений -- Раздельная обработка сообщений пользователя и ассистента -- Сохранение ключевой информации при сжатии - -### 3. Настраиваемые параметры -- `EnableHistoryCompression` - включение/отключение сжатия -- `CompressionThreshold` - порог активации сжатия (по умолчанию 20 сообщений) -- `CompressionTarget` - целевое количество сообщений после сжатия (по умолчанию 10) -- `MinMessageLengthForSummarization` - минимальная длина сообщения для суммаризации (50 символов) -- `MaxSummarizedMessageLength` - максимальная длина сжатого сообщения (200 символов) - -## Архитектура - -### Новые компоненты - -1. **IHistoryCompressionService** - интерфейс для сжатия истории -2. **HistoryCompressionService** - реализация сервиса сжатия -3. **Обновленный ChatSession** - поддержка асинхронного сжатия -4. **Обновленный AIService** - интеграция сжатия в генерацию ответов -5. **Обновленный ChatService** - использование сжатия при обработке сообщений - -### Алгоритм сжатия - -1. **Проверка необходимости сжатия** - сравнение количества сообщений с порогом -2. **Разделение сообщений** - отделение системных, старых и новых сообщений -3. **Группировка по ролям** - отдельная обработка сообщений пользователя и ассистента -4. **Суммаризация** - создание кратких резюме с помощью ИИ -5. **Объединение** - формирование финального списка сообщений - -## Конфигурация - -### appsettings.json -```json -{ - "AI": { - "EnableHistoryCompression": true, - "CompressionThreshold": 20, - "CompressionTarget": 10, - "MinMessageLengthForSummarization": 50, - "MaxSummarizedMessageLength": 200 - } -} -``` - -## Использование - -### Автоматическое сжатие -Сжатие происходит автоматически при добавлении новых сообщений, если включено в настройках. - -### Мониторинг -Команда `/settings` показывает текущее состояние сжатия и параметры. - -## Преимущества - -1. **Экономия памяти** - значительное сокращение использования RAM -2. **Улучшенная производительность** - быстрее обработка длинных диалогов -3. **Сохранение контекста** - важная информация не теряется -4. **Гибкость настройки** - возможность адаптации под различные сценарии -5. **Обратная совместимость** - можно отключить без изменения кода - -## Обработка ошибок - -- При ошибках сжатия система автоматически переключается на простое обрезание истории -- Логирование всех операций сжатия для мониторинга -- Graceful degradation - бот продолжает работать даже при проблемах со сжатием - -## Производительность - -- Сжатие выполняется асинхронно, не блокируя основной поток -- Использование кэширования для оптимизации повторных операций -- Минимальное влияние на время отклика бота diff --git a/ChatBot/Migrations/20251016214154_InitialCreate.Designer.cs b/ChatBot/Migrations/20251016214154_InitialCreate.Designer.cs new file mode 100644 index 0000000..016d854 --- /dev/null +++ b/ChatBot/Migrations/20251016214154_InitialCreate.Designer.cs @@ -0,0 +1,149 @@ +// +using System; +using ChatBot.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ChatBot.Migrations +{ + [DbContext(typeof(ChatBotDbContext))] + [Migration("20251016214154_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ChatBot.Models.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("MessageOrder") + .HasColumnType("integer") + .HasColumnName("message_order"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("SessionId") + .HasColumnType("integer") + .HasColumnName("session_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SessionId"); + + b.HasIndex("SessionId", "MessageOrder"); + + b.ToTable("chat_messages", (string)null); + }); + + modelBuilder.Entity("ChatBot.Models.Entities.ChatSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChatId") + .HasColumnType("bigint") + .HasColumnName("chat_id"); + + b.Property("ChatTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("chat_title"); + + b.Property("ChatType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("chat_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_updated_at"); + + b.Property("MaxHistoryLength") + .HasColumnType("integer") + .HasColumnName("max_history_length"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("model"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("session_id"); + + b.HasKey("Id"); + + b.HasIndex("ChatId"); + + b.HasIndex("SessionId") + .IsUnique(); + + b.ToTable("chat_sessions", (string)null); + }); + + modelBuilder.Entity("ChatBot.Models.Entities.ChatMessageEntity", b => + { + b.HasOne("ChatBot.Models.Entities.ChatSessionEntity", "Session") + .WithMany("Messages") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("ChatBot.Models.Entities.ChatSessionEntity", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ChatBot/Migrations/20251016214154_InitialCreate.cs b/ChatBot/Migrations/20251016214154_InitialCreate.cs new file mode 100644 index 0000000..5d1b90e --- /dev/null +++ b/ChatBot/Migrations/20251016214154_InitialCreate.cs @@ -0,0 +1,95 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ChatBot.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "chat_sessions", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + session_id = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + chat_id = table.Column(type: "bigint", nullable: false), + chat_type = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + chat_title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + model = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + last_updated_at = table.Column(type: "timestamp with time zone", nullable: false), + max_history_length = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_chat_sessions", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "chat_messages", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + session_id = table.Column(type: "integer", nullable: false), + content = table.Column(type: "character varying(10000)", maxLength: 10000, nullable: false), + role = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + message_order = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_chat_messages", x => x.id); + table.ForeignKey( + name: "FK_chat_messages_chat_sessions_session_id", + column: x => x.session_id, + principalTable: "chat_sessions", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_chat_messages_created_at", + table: "chat_messages", + column: "created_at"); + + migrationBuilder.CreateIndex( + name: "IX_chat_messages_session_id", + table: "chat_messages", + column: "session_id"); + + migrationBuilder.CreateIndex( + name: "IX_chat_messages_session_id_message_order", + table: "chat_messages", + columns: new[] { "session_id", "message_order" }); + + migrationBuilder.CreateIndex( + name: "IX_chat_sessions_chat_id", + table: "chat_sessions", + column: "chat_id"); + + migrationBuilder.CreateIndex( + name: "IX_chat_sessions_session_id", + table: "chat_sessions", + column: "session_id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "chat_messages"); + + migrationBuilder.DropTable( + name: "chat_sessions"); + } + } +} diff --git a/ChatBot/Migrations/ChatBotDbContextModelSnapshot.cs b/ChatBot/Migrations/ChatBotDbContextModelSnapshot.cs new file mode 100644 index 0000000..8d434b9 --- /dev/null +++ b/ChatBot/Migrations/ChatBotDbContextModelSnapshot.cs @@ -0,0 +1,146 @@ +// +using System; +using ChatBot.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ChatBot.Migrations +{ + [DbContext(typeof(ChatBotDbContext))] + partial class ChatBotDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ChatBot.Models.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("MessageOrder") + .HasColumnType("integer") + .HasColumnName("message_order"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("SessionId") + .HasColumnType("integer") + .HasColumnName("session_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SessionId"); + + b.HasIndex("SessionId", "MessageOrder"); + + b.ToTable("chat_messages", (string)null); + }); + + modelBuilder.Entity("ChatBot.Models.Entities.ChatSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChatId") + .HasColumnType("bigint") + .HasColumnName("chat_id"); + + b.Property("ChatTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("chat_title"); + + b.Property("ChatType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("chat_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_updated_at"); + + b.Property("MaxHistoryLength") + .HasColumnType("integer") + .HasColumnName("max_history_length"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("model"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("session_id"); + + b.HasKey("Id"); + + b.HasIndex("ChatId"); + + b.HasIndex("SessionId") + .IsUnique(); + + b.ToTable("chat_sessions", (string)null); + }); + + modelBuilder.Entity("ChatBot.Models.Entities.ChatMessageEntity", b => + { + b.HasOne("ChatBot.Models.Entities.ChatSessionEntity", "Session") + .WithMany("Messages") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("ChatBot.Models.Entities.ChatSessionEntity", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ChatBot/Models/Configuration/DatabaseSettings.cs b/ChatBot/Models/Configuration/DatabaseSettings.cs new file mode 100644 index 0000000..5c7616a --- /dev/null +++ b/ChatBot/Models/Configuration/DatabaseSettings.cs @@ -0,0 +1,23 @@ +namespace ChatBot.Models.Configuration +{ + /// + /// Database configuration settings + /// + public class DatabaseSettings + { + /// + /// Connection string for the database + /// + public string ConnectionString { get; set; } = string.Empty; + + /// + /// Enable sensitive data logging (for development only) + /// + public bool EnableSensitiveDataLogging { get; set; } = false; + + /// + /// Command timeout in seconds + /// + public int CommandTimeout { get; set; } = 30; + } +} diff --git a/ChatBot/Models/Configuration/Validators/DatabaseSettingsValidator.cs b/ChatBot/Models/Configuration/Validators/DatabaseSettingsValidator.cs new file mode 100644 index 0000000..3e9e7ff --- /dev/null +++ b/ChatBot/Models/Configuration/Validators/DatabaseSettingsValidator.cs @@ -0,0 +1,40 @@ +using FluentValidation; +using Microsoft.Extensions.Options; + +namespace ChatBot.Models.Configuration.Validators +{ + /// + /// Validator for DatabaseSettings + /// + public class DatabaseSettingsValidator + : AbstractValidator, + IValidateOptions + { + public DatabaseSettingsValidator() + { + RuleFor(x => x.ConnectionString) + .NotEmpty() + .WithMessage("Database connection string is required"); + + RuleFor(x => x.CommandTimeout) + .GreaterThan(0) + .WithMessage("Command timeout must be greater than 0"); + + RuleFor(x => x.CommandTimeout) + .LessThanOrEqualTo(300) + .WithMessage("Command timeout must be less than or equal to 300 seconds"); + } + + public ValidateOptionsResult Validate(string? name, DatabaseSettings options) + { + var result = Validate(options); + if (result.IsValid) + { + return ValidateOptionsResult.Success; + } + + var errors = result.Errors.Select(e => e.ErrorMessage).ToArray(); + return ValidateOptionsResult.Fail(errors); + } + } +} diff --git a/ChatBot/Models/Entities/ChatMessageEntity.cs b/ChatBot/Models/Entities/ChatMessageEntity.cs new file mode 100644 index 0000000..91f8e05 --- /dev/null +++ b/ChatBot/Models/Entities/ChatMessageEntity.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace ChatBot.Models.Entities +{ + /// + /// Entity model for chat message stored in database + /// + [Table("chat_messages")] + public class ChatMessageEntity + { + /// + /// Primary key + /// + [Key] + [Column("id")] + public int Id { get; set; } + + /// + /// Foreign key to chat session + /// + [Required] + [Column("session_id")] + public int SessionId { get; set; } + + /// + /// The content of the message + /// + [Required] + [Column("content")] + [StringLength(10000)] + public string Content { get; set; } = string.Empty; + + /// + /// The role of the message author (system, user, assistant) + /// + [Required] + [Column("role")] + [StringLength(20)] + public string Role { get; set; } = string.Empty; + + /// + /// When the message was created + /// + [Required] + [Column("created_at")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Order of the message in the conversation + /// + [Column("message_order")] + public int MessageOrder { get; set; } + + /// + /// Navigation property to chat session + /// + [ForeignKey("SessionId")] + public virtual ChatSessionEntity Session { get; set; } = null!; + } +} diff --git a/ChatBot/Models/Entities/ChatSessionEntity.cs b/ChatBot/Models/Entities/ChatSessionEntity.cs new file mode 100644 index 0000000..ed2ce78 --- /dev/null +++ b/ChatBot/Models/Entities/ChatSessionEntity.cs @@ -0,0 +1,82 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace ChatBot.Models.Entities +{ + /// + /// Entity model for chat session stored in database + /// + [Table("chat_sessions")] + public class ChatSessionEntity + { + /// + /// Primary key + /// + [Key] + [Column("id")] + public int Id { get; set; } + + /// + /// Unique identifier for the chat session + /// + [Required] + [Column("session_id")] + [StringLength(50)] + public string SessionId { get; set; } = string.Empty; + + /// + /// Telegram chat ID (can be private chat or group chat) + /// + [Required] + [Column("chat_id")] + public long ChatId { get; set; } + + /// + /// Chat type (private, group, supergroup, channel) + /// + [Required] + [Column("chat_type")] + [StringLength(20)] + public string ChatType { get; set; } = "private"; + + /// + /// Chat title (for groups) + /// + [Column("chat_title")] + [StringLength(200)] + public string ChatTitle { get; set; } = string.Empty; + + /// + /// AI model to use for this session + /// + [Column("model")] + [StringLength(100)] + public string Model { get; set; } = string.Empty; + + /// + /// When the session was created + /// + [Required] + [Column("created_at")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// When the session was last updated + /// + [Required] + [Column("last_updated_at")] + public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Maximum number of messages to keep in history + /// + [Column("max_history_length")] + public int MaxHistoryLength { get; set; } = 30; + + /// + /// Navigation property for messages + /// + public virtual ICollection Messages { get; set; } = + new List(); + } +} diff --git a/ChatBot/Program.cs b/ChatBot/Program.cs index 1acdf0e..f7edebe 100644 --- a/ChatBot/Program.cs +++ b/ChatBot/Program.cs @@ -1,3 +1,6 @@ +using ChatBot.Data; +using ChatBot.Data.Interfaces; +using ChatBot.Data.Repositories; using ChatBot.Models.Configuration; using ChatBot.Models.Configuration.Validators; using ChatBot.Services; @@ -6,6 +9,7 @@ using ChatBot.Services.Interfaces; using ChatBot.Services.Telegram.Commands; using ChatBot.Services.Telegram.Interfaces; using ChatBot.Services.Telegram.Services; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Serilog; using Telegram.Bot; @@ -35,10 +39,15 @@ try .Services.Configure(builder.Configuration.GetSection("AI")) .AddSingleton, AISettingsValidator>(); + builder + .Services.Configure(builder.Configuration.GetSection("Database")) + .AddSingleton, DatabaseSettingsValidator>(); + // Валидируем конфигурацию при старте builder.Services.AddOptions().ValidateOnStart(); builder.Services.AddOptions().ValidateOnStart(); builder.Services.AddOptions().ValidateOnStart(); + builder.Services.AddOptions().ValidateOnStart(); // Регистрируем IOllamaClient builder.Services.AddSingleton(sp => @@ -47,22 +56,49 @@ try return new OllamaClientAdapter(settings.Value.Url); }); + // Регистрируем Entity Framework и базу данных + builder.Services.AddDbContext( + (serviceProvider, options) => + { + var dbSettings = serviceProvider.GetRequiredService>().Value; + options.UseNpgsql( + dbSettings.ConnectionString, + npgsqlOptions => + { + npgsqlOptions.CommandTimeout(dbSettings.CommandTimeout); + } + ); + + if (dbSettings.EnableSensitiveDataLogging) + { + options.EnableSensitiveDataLogging(); + } + } + ); + + // Регистрируем репозиторий + builder.Services.AddScoped(); + // Регистрируем интерфейсы и сервисы - builder.Services.AddSingleton(); + // Можно переключиться между InMemorySessionStorage и DatabaseSessionStorage + builder.Services.AddScoped(); // Регистрируем основные сервисы builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddScoped(); + + // Регистрируем сервис инициализации базы данных + builder.Services.AddHostedService(); // Регистрируем Telegram команды - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); // Регистрируем Telegram сервисы builder.Services.AddSingleton(provider => @@ -72,17 +108,24 @@ try }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddScoped(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); - // Регистрируем TelegramBotService как singleton и используем один экземпляр для интерфейса и HostedService - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => + // Регистрируем TelegramBotService как scoped и используем один экземпляр для интерфейса и HostedService + builder.Services.AddScoped(); + builder.Services.AddScoped(sp => sp.GetRequiredService() ); - builder.Services.AddHostedService(sp => sp.GetRequiredService()); + + // Создаем обертку для HostedService, которая создает scope + builder.Services.AddSingleton(sp => + { + var serviceProvider = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + return new TelegramBotHostedService(serviceProvider, logger); + }); // Регистрируем Health Checks builder diff --git a/ChatBot/Services/ChatService.cs b/ChatBot/Services/ChatService.cs index ecf7509..d29d53a 100644 --- a/ChatBot/Services/ChatService.cs +++ b/ChatBot/Services/ChatService.cs @@ -83,6 +83,9 @@ namespace ChatBot.Services session.AddUserMessage(message, username); } + // Save session changes to database + await _sessionStorage.SaveSessionAsync(session); + _logger.LogInformation( "Processing message from user {Username} in chat {ChatId} ({ChatType}): {Message}", username, @@ -130,6 +133,9 @@ namespace ChatBot.Services session.AddAssistantMessage(response); } + // Save session changes to database + await _sessionStorage.SaveSessionAsync(session); + _logger.LogDebug( "AI response generated for chat {ChatId} (length: {Length})", chatId, @@ -149,7 +155,7 @@ namespace ChatBot.Services /// /// Update session parameters /// - public void UpdateSessionParameters(long chatId, string? model = null) + public async Task UpdateSessionParametersAsync(long chatId, string? model = null) { var session = _sessionStorage.Get(chatId); if (session != null) @@ -158,6 +164,10 @@ namespace ChatBot.Services session.Model = model; session.LastUpdatedAt = DateTime.UtcNow; + + // Save session changes to database + await _sessionStorage.SaveSessionAsync(session); + _logger.LogInformation("Updated session parameters for chat {ChatId}", chatId); } } @@ -165,12 +175,16 @@ namespace ChatBot.Services /// /// Clear chat history for a session /// - public void ClearHistory(long chatId) + public async Task ClearHistoryAsync(long chatId) { var session = _sessionStorage.Get(chatId); if (session != null) { session.ClearHistory(); + + // Save session changes to database + await _sessionStorage.SaveSessionAsync(session); + _logger.LogInformation("Cleared history for chat {ChatId}", chatId); } } diff --git a/ChatBot/Services/DatabaseInitializationService.cs b/ChatBot/Services/DatabaseInitializationService.cs new file mode 100644 index 0000000..8381d86 --- /dev/null +++ b/ChatBot/Services/DatabaseInitializationService.cs @@ -0,0 +1,74 @@ +using ChatBot.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ChatBot.Services +{ + /// + /// Service for initializing database on application startup + /// + public class DatabaseInitializationService : IHostedService + { + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public DatabaseInitializationService( + IServiceProvider serviceProvider, + ILogger logger + ) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting database initialization..."); + + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + try + { + // Check if database exists and is accessible + var canConnect = await context.Database.CanConnectAsync(cancellationToken); + if (!canConnect) + { + _logger.LogInformation( + "Database does not exist. Creating database and applying migrations..." + ); + } + else + { + _logger.LogInformation("Database exists. Applying migrations..."); + } + + // Ensure database is created and migrations are applied + await context.Database.MigrateAsync(cancellationToken); + + _logger.LogInformation("Database initialization completed successfully"); + } + catch (Exception ex) + when (ex.Message.Contains("database") && ex.Message.Contains("does not exist")) + { + // This is expected when database doesn't exist - MigrateAsync will create it + _logger.LogInformation("Database does not exist, will be created during migration"); + await context.Database.MigrateAsync(cancellationToken); + _logger.LogInformation("Database initialization completed successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize database"); + throw new InvalidOperationException("Database initialization failed", ex); + } + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Database initialization service stopped"); + return Task.CompletedTask; + } + } +} diff --git a/ChatBot/Services/DatabaseSessionStorage.cs b/ChatBot/Services/DatabaseSessionStorage.cs new file mode 100644 index 0000000..dce80f7 --- /dev/null +++ b/ChatBot/Services/DatabaseSessionStorage.cs @@ -0,0 +1,189 @@ +using ChatBot.Data.Interfaces; +using ChatBot.Models; +using ChatBot.Models.Dto; +using ChatBot.Models.Entities; +using ChatBot.Services.Interfaces; +using OllamaSharp.Models.Chat; + +namespace ChatBot.Services +{ + /// + /// Database implementation of session storage + /// + public class DatabaseSessionStorage : ISessionStorage + { + private readonly IChatSessionRepository _repository; + private readonly ILogger _logger; + private readonly IHistoryCompressionService? _compressionService; + + public DatabaseSessionStorage( + IChatSessionRepository repository, + ILogger logger, + IHistoryCompressionService? compressionService = null + ) + { + _repository = repository; + _logger = logger; + _compressionService = compressionService; + } + + public ChatSession GetOrCreate( + long chatId, + string chatType = "private", + string chatTitle = "" + ) + { + try + { + var sessionEntity = _repository + .GetOrCreateAsync(chatId, chatType, chatTitle) + .GetAwaiter() + .GetResult(); + return ConvertToChatSession(sessionEntity); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get or create session for chat {ChatId}", chatId); + throw new InvalidOperationException( + $"Failed to get or create session for chat {chatId}", + ex + ); + } + } + + public ChatSession? Get(long chatId) + { + try + { + var sessionEntity = _repository.GetByChatIdAsync(chatId).GetAwaiter().GetResult(); + return sessionEntity != null ? ConvertToChatSession(sessionEntity) : null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get session for chat {ChatId}", chatId); + return null; + } + } + + public bool Remove(long chatId) + { + try + { + return _repository.DeleteAsync(chatId).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to remove session for chat {ChatId}", chatId); + return false; + } + } + + public int GetActiveSessionsCount() + { + try + { + return _repository.GetActiveSessionsCountAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get active sessions count"); + return 0; + } + } + + public int CleanupOldSessions(int hoursOld = 24) + { + try + { + return _repository.CleanupOldSessionsAsync(hoursOld).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cleanup old sessions"); + return 0; + } + } + + /// + /// Save session changes to database + /// + public async Task SaveSessionAsync(ChatSession session) + { + try + { + var sessionEntity = await _repository.GetByChatIdAsync(session.ChatId); + if (sessionEntity == null) + { + _logger.LogWarning( + "Session not found for chat {ChatId} during save", + session.ChatId + ); + return; + } + + // Update session properties + sessionEntity.Model = session.Model; + sessionEntity.MaxHistoryLength = session.MaxHistoryLength; + sessionEntity.LastUpdatedAt = DateTime.UtcNow; + + // Clear existing messages and add new ones + await _repository.ClearMessagesAsync(sessionEntity.Id); + + var messages = session.GetAllMessages(); + for (int i = 0; i < messages.Count; i++) + { + await _repository.AddMessageAsync( + sessionEntity.Id, + messages[i].Content, + messages[i].Role.ToString(), + i + ); + } + + await _repository.UpdateAsync(sessionEntity); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save session for chat {ChatId}", session.ChatId); + throw new InvalidOperationException( + $"Failed to save session for chat {session.ChatId}", + ex + ); + } + } + + /// + /// Convert ChatSessionEntity to ChatSession + /// + private ChatSession ConvertToChatSession(ChatSessionEntity entity) + { + var session = new ChatSession + { + SessionId = entity.SessionId, + ChatId = entity.ChatId, + ChatType = entity.ChatType, + ChatTitle = entity.ChatTitle, + Model = entity.Model, + CreatedAt = entity.CreatedAt, + LastUpdatedAt = entity.LastUpdatedAt, + MaxHistoryLength = entity.MaxHistoryLength, + }; + + // Set compression service if available + if (_compressionService != null) + { + session.SetCompressionService(_compressionService); + } + + // Add messages to session + foreach (var messageEntity in entity.Messages.OrderBy(m => m.MessageOrder)) + { + var role = Enum.Parse(messageEntity.Role); + var message = new ChatMessage { Content = messageEntity.Content, Role = role }; + session.AddMessage(message); + } + + return session; + } + } +} diff --git a/ChatBot/Services/InMemorySessionStorage.cs b/ChatBot/Services/InMemorySessionStorage.cs index 77a1ee4..78dca3b 100644 --- a/ChatBot/Services/InMemorySessionStorage.cs +++ b/ChatBot/Services/InMemorySessionStorage.cs @@ -98,5 +98,12 @@ namespace ChatBot.Services return sessionsToRemove.Count; } + + public Task SaveSessionAsync(ChatSession session) + { + // For in-memory storage, no additional save is needed + // The session is already in memory and will be updated automatically + return Task.CompletedTask; + } } } diff --git a/ChatBot/Services/Interfaces/ISessionStorage.cs b/ChatBot/Services/Interfaces/ISessionStorage.cs index 8b23691..0b9e6c6 100644 --- a/ChatBot/Services/Interfaces/ISessionStorage.cs +++ b/ChatBot/Services/Interfaces/ISessionStorage.cs @@ -31,5 +31,10 @@ namespace ChatBot.Services.Interfaces /// Clean up old sessions /// int CleanupOldSessions(int hoursOld = 24); + + /// + /// Save session changes to storage (for database implementations) + /// + Task SaveSessionAsync(ChatSession session); } } diff --git a/ChatBot/Services/Telegram/Commands/ClearCommand.cs b/ChatBot/Services/Telegram/Commands/ClearCommand.cs index b277146..fa5b115 100644 --- a/ChatBot/Services/Telegram/Commands/ClearCommand.cs +++ b/ChatBot/Services/Telegram/Commands/ClearCommand.cs @@ -12,13 +12,13 @@ namespace ChatBot.Services.Telegram.Commands public override string CommandName => "/clear"; public override string Description => "Очистить историю чата"; - public override Task ExecuteAsync( + public override async Task ExecuteAsync( TelegramCommandContext context, CancellationToken cancellationToken = default ) { - _chatService.ClearHistory(context.ChatId); - return Task.FromResult("История чата очищена. Начинаем новый разговор!"); + await _chatService.ClearHistoryAsync(context.ChatId); + return "История чата очищена. Начинаем новый разговор!"; } } } diff --git a/ChatBot/Services/TelegramBotHostedService.cs b/ChatBot/Services/TelegramBotHostedService.cs new file mode 100644 index 0000000..4d56753 --- /dev/null +++ b/ChatBot/Services/TelegramBotHostedService.cs @@ -0,0 +1,46 @@ +using ChatBot.Services.Telegram.Interfaces; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ChatBot.Services +{ + /// + /// Hosted service wrapper for TelegramBotService to handle scoped dependencies + /// + public class TelegramBotHostedService : IHostedService + { + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private ITelegramBotService? _botService; + + public TelegramBotHostedService( + IServiceProvider serviceProvider, + ILogger logger + ) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting Telegram Bot Hosted Service..."); + + using var scope = _serviceProvider.CreateScope(); + _botService = scope.ServiceProvider.GetRequiredService(); + + await _botService.StartAsync(cancellationToken); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Stopping Telegram Bot Hosted Service..."); + + if (_botService != null) + { + await _botService.StopAsync(cancellationToken); + } + } + } +} diff --git a/ChatBot/appsettings.json b/ChatBot/appsettings.json index cf82639..62e0096 100644 --- a/ChatBot/appsettings.json +++ b/ChatBot/appsettings.json @@ -5,6 +5,8 @@ "Default": "Information", "Override": { "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore.Database.Connection": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", "System": "Warning", "Telegram.Bot": "Information" } @@ -50,5 +52,10 @@ "MaxRetryDelayMs": 30000, "CompressionTimeoutSeconds": 30, "StatusCheckTimeoutSeconds": 10 + }, + "Database": { + "ConnectionString": "Host=localhost;Port=5432;Database=chatbot;Username=postgres;Password=postgres", + "EnableSensitiveDataLogging": false, + "CommandTimeout": 30 } }