diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..55d47bea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,75 @@ +# Git +.git +.gitignore + +# Documentation +*.md +README.md +DIALOGFLOW_EMULATOR.md + +# IDE files +.vs/ +.vscode/ +.idea/ +*.suo +*.user +*.userprefs +*.sln.docstates + +# Build artifacts +bin/ +obj/ +out/ +target/ + +# Node modules (except in dialogflow-emulator) +node_modules/ +!dialogflow-emulator/node_modules/ + +# Logs +*.log +logs/ + +# Temporary files +tmp/ +temp/ +.tmp/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Application specific +appsettings.*.json +!appsettings.json +!appsettings.Local.json + +# Test results +TestResults/ +[Tt]est[Rr]esults*/ + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# NuGet +*.nupkg +*.snupkg +.nuget/ \ No newline at end of file diff --git a/.github/workflows/build&test.yml b/.github/workflows/build&test.yml index d4b0da26..45c1439c 100644 --- a/.github/workflows/build&test.yml +++ b/.github/workflows/build&test.yml @@ -12,10 +12,73 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 + - name: Setup .NET - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v5 with: - dotnet-version: 6.0.x + dotnet-version: 9.0.x + - name: Build and test - run: dotnet test --verbosity normal src/FillInTheTextBot.sln + run: dotnet test --verbosity normal FillInTheTextBot.slnx + + - name: Publish + run: dotnet publish --configuration Release --output ./output src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj + + - name: Build image + uses: docker/build-push-action@v6.18.0 + with: + tags: granstel/fillinthetextbot:latest + load: true + push: false + context: . + file: src/FillInTheTextBot.Api/Dockerfile + + - name: Compose + uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 + with: + compose-file: docker-compose.ci.yml + up-flags: --build + + - name: Collect per-service logs + if: always() + run: | + mkdir -p compose-logs + for s in $(docker compose -f docker-compose.ci.yml config --services); do + echo "Collecting logs for $s" + docker compose -f docker-compose.ci.yml logs --no-color -t "$s" > "compose-logs/${s}.log" || true + done + + - name: Upload per-service logs folder + if: failure() + uses: actions/upload-artifact@v4.6.2 + with: + name: compose-logs + path: compose-logs + retention-days: 7 + + - name: Collect docker-compose.log + if: always() + run: docker compose -f docker-compose.ci.yml logs -t >> docker-compose.log || true + - name: Upload docker-compose.log + if: always() + uses: actions/upload-artifact@v4.6.2 + with: + name: docker-compose.log + path: docker-compose.log + retention-days: 7 + + - name: Login to Docker Hub + if: ${{ github.ref == 'refs/heads/main' }} + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Push to hub + if: ${{ github.ref == 'refs/heads/main' }} + uses: docker/build-push-action@v6.18.0 + with: + context: . + tags: granstel/fillinthetextbot:latest + push: true diff --git a/.gitignore b/.gitignore index 995d0975..63b5e8dc 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,6 @@ **/Keys/ **/deploy/ **/.config/ -**/Properties/ /src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj.user /src/FillInTheTextBot.Api/fillinthetextbot-test-bctxpk-bb6ea1e87cd1.json /src/FillInTheTextBot.Api/fillinthetextbot-vyyaxp-c062f43624f6.json diff --git a/FillInTheTextBot.slnx b/FillInTheTextBot.slnx new file mode 100644 index 00000000..3c6c50e5 --- /dev/null +++ b/FillInTheTextBot.slnx @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/MEMORY_LEAK_FIXES.md b/MEMORY_LEAK_FIXES.md new file mode 100644 index 00000000..0559c47f --- /dev/null +++ b/MEMORY_LEAK_FIXES.md @@ -0,0 +1,79 @@ +# Исправления утечек памяти в FillInTheTextBot + +## Обнаруженные и исправленные проблемы: + +### 1. **Tracing.cs - Утечка ActivitySource** +**Проблема**: Статический `ActivitySource` создавался, но никогда не освобождался. +**Исправление**: Добавили обработчики событий `AppDomain.CurrentDomain.ProcessExit` и `AppDomain.CurrentDomain.DomainUnload` для вызова `Dispose()`. + +### 2. **MetricsCollector.cs - Утечка Meter** +**Проблема**: Статический `Meter` создавался, но никогда не освобождался. +**Исправление**: Сохраняем ссылку на `Meter` и добавили обработчики событий завершения приложения для его освобождения. + +### 3. **ExternalServicesRegistration.cs - Утечка ConnectionMultiplexer** +**Проблема**: `ConnectionMultiplexer` создавался каждый раз при вызове `RegisterRedisClient`, что могло приводить к множественным подключениям к Redis. +**Исправление**: Изменили архитектуру - теперь `ConnectionMultiplexer` регистрируется как Singleton отдельно, а `IDatabase` получается из него. + +### 4. **gRPC клиенты - Потенциальные утечки и неэффективность** +**Проблема**: `SessionsClient` и `ContextsClient` могли создаваться повторно для каждого запроса, не переиспользовались. +**Исправление**: Создали `GrpcClientManager` который: +- Кэширует клиенты по ScopeId +- Реализует IDisposable для правильного освобождения ресурсов +- Пытается вызвать Dispose/DisposeAsync на клиентах при завершении + +### 5. **TasksExtensions.cs - Проблемы с Task.Factory.StartNew** +**Проблема**: Использование `Task.Factory.StartNew` вместо `Task.Run` может приводить к проблемам с управлением памятью. +**Исправление**: Заменили на `Task.Run` который лучше управляет thread pool и ресурсами. + +### 6. **Startup.cs - ActivityListener не освобождался** +**Проблема**: `ActivityListener` создавался, но не освобождался при завершении приложения. +**Исправление**: Добавили освобождение в `ApplicationStopping` event. + +## Добавленные инструменты диагностики: + +### 1. **MemoryDiagnostics.cs** +- Класс для мониторинга использования памяти +- Логирование текущего использования памяти +- Принудительная сборка мусора с логированием +- Периодический мониторинг памяти + +### 2. **MemoryMonitoringMiddleware.cs** +- Middleware для отслеживания памяти на каждый HTTP запрос +- Логирует использование памяти до и после обработки запроса + +### 3. **GrpcClientManager.cs** +- Менеджер для управления жизненным циклом gRPC клиентов +- Кэширование клиентов +- Правильное освобождение ресурсов + +## Интеграция в приложение: + +1. **Инициализация диагностики** в `Startup.cs`: + - Инициализация `MemoryDiagnostics` + - Периодический мониторинг памяти (каждые 5 минут) + +2. **Middleware pipeline**: + - Добавлен `MemoryMonitoringMiddleware` после `ExceptionsMiddleware` + +3. **DI контейнер**: + - `GrpcClientManager` зарегистрирован как Singleton + - `ConnectionMultiplexer` правильно зарегистрирован + +## Рекомендации по мониторингу: + +1. **Логи памяти**: Следите за логами с префиксом "Memory usage" и "Memory diagnostics" + +2. **Метрики**: Используйте существующую Prometheus интеграцию для мониторинга: + - Количество сборок мусора (Gen0, Gen1, Gen2) + - Использование памяти приложением + +3. **Периодическая диагностика**: Логи будут показывать использование памяти каждые 5 минут + +4. **При высокой нагрузке**: Активируйте логирование памяти для каждого запроса (уже включено) + +## Потенциальные дополнительные улучшения: + +1. **WeakReference** для кэшей, которые могут расти +2. **IMemoryCache** с TTL для временных данных +3. **Профилирование** с помощью dotMemory или PerfView +4. **Monitoring**: Настроить алерты в Prometheus/Grafana для роста памяти \ No newline at end of file diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index f59783eb..00000000 --- a/appveyor.yml +++ /dev/null @@ -1,9 +0,0 @@ -version: 1.1.0.{build} -branches: - only: - - master -image: Visual Studio 2019 -build_script: -- cmd: dotnet build src\FillInTheTextBot.sln -c Release -test_script: -- cmd: dotnet test "src\FillInTheTextBot.sln" -c Release diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 00000000..1788b573 --- /dev/null +++ b/docker-compose.ci.yml @@ -0,0 +1,33 @@ +services: + dialogflow-emulator: + build: + context: . + dockerfile: src/Dialogflow.Emulator/Dockerfile + volumes: + - ./Dialogflow/FillInTheTextBot-test-eu:/app/agent:ro + environment: + - AGENT_PATH=/app/agent + - Kestrel__Endpoints__Grpc__Url=http://0.0.0.0:8195 + read_only: true + security_opt: + - no-new-privileges:true + + redis: + image: redis:alpine + container_name: fillinthetextbot-redis + ports: + - "6379" + + FillInTheTextBot: + image: granstel/fillinthetextbot:latest + environment: + - ASPNETCORE_URLS=http://+:8080 + - ASPNETCORE_ENVIRONMENT=CI + read_only: true + security_opt: + - no-new-privileges:true + depends_on: + dialogflow-emulator: + condition: service_started + redis: + condition: service_started diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..5bda668a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + dialogflow-emulator: + build: + context: . + dockerfile: src/Dialogflow.Emulator/Dockerfile + ports: + - "7195:7195" + volumes: + - ./Dialogflow/FillInTheTextBot-test-eu:/app/agent:ro + environment: + - AGENT_PATH=/app/agent + - Kestrel__Endpoints__Grpc__Url=http://0.0.0.0:7195 + read_only: true + security_opt: + - no-new-privileges:true + + redis: + image: redis:alpine + container_name: fillinthetextbot-redis + ports: + - "6379:6379" diff --git a/src/.zencoder/docs/repo.md b/src/.zencoder/docs/repo.md new file mode 100644 index 00000000..89bc1d52 --- /dev/null +++ b/src/.zencoder/docs/repo.md @@ -0,0 +1,72 @@ +# FillInTheTextBot Information + +## Summary +FillInTheTextBot is a .NET-based conversational bot platform that integrates with multiple voice assistant platforms including Yandex, Sber, and Marusia. The application uses Dialogflow for natural language processing and provides a unified API for handling conversations across different messenger platforms. + +## Structure +- **FillInTheTextBot.Api**: Main API entry point and web application host +- **FillInTheTextBot.Models**: Shared data models used across the application +- **FillInTheTextBot.Services**: Core business logic and services +- **FillInTheTextBot.Messengers**: Base messenger integration framework +- **FillInTheTextBot.Messengers.***: Platform-specific implementations (Yandex, Sber, Marusia) +- **Tests**: Multiple test projects for different components + +## Language & Runtime +**Language**: C# +**Framework**: ASP.NET Core +**Version**: .NET 6.0 +**Build System**: MSBuild (Visual Studio) +**Package Manager**: NuGet + +## Dependencies +**Main Dependencies**: +- Google.Cloud.Dialogflow.V2: Natural language processing integration +- NLog/NLog.Web.AspNetCore: Logging framework +- Newtonsoft.Json: JSON serialization/deserialization +- OpenTracing/Jaeger: Distributed tracing +- prometheus-net: Metrics collection and monitoring + +**Development Dependencies**: +- NUnit: Testing framework +- Moq: Mocking library for unit tests +- AutoFixture: Test data generation + +## Build & Installation +```bash +dotnet restore +dotnet build +dotnet run --project FillInTheTextBot.Api/FillInTheTextBot.Api.csproj +``` + +## Docker +**Dockerfile**: FillInTheTextBot.Api/Dockerfile +**Base Image**: mcr.microsoft.com/dotnet/aspnet:6.0 +**Build Image**: mcr.microsoft.com/dotnet/sdk:6.0 +**Exposed Port**: 80 +**Build Command**: +```bash +docker build -t fillinthetext-bot -f FillInTheTextBot.Api/Dockerfile . +``` + +## Application Structure +**Entry Point**: Program.cs in FillInTheTextBot.Api +**Configuration**: Startup.cs handles service registration and middleware configuration +**Main Components**: +- **DialogflowService**: Handles NLP processing through Google's Dialogflow +- **ConversationService**: Manages conversation state and flow +- **Messenger Services**: Platform-specific implementations for different voice assistants + - YandexService: Integration with Yandex Alice + - SberService: Integration with Sber Salut + - MarusiaService: Integration with Marusia + +## Testing +**Framework**: NUnit +**Test Locations**: +- FillInTheTextBot.Services.Tests +- FillInTheTextBot.Messengers.Tests +- FillInTheTextBot.Messengers.Yandex.Tests +**Tools**: Moq for mocking, AutoFixture for test data generation +**Run Command**: +```bash +dotnet test +``` \ No newline at end of file diff --git a/src/Dialogflow.Emulator.Client/Dialogflow.Emulator.Client.csproj b/src/Dialogflow.Emulator.Client/Dialogflow.Emulator.Client.csproj new file mode 100644 index 00000000..498abab9 --- /dev/null +++ b/src/Dialogflow.Emulator.Client/Dialogflow.Emulator.Client.csproj @@ -0,0 +1,13 @@ + + + Exe + net9.0 + enable + enable + latest + + + + + + diff --git a/src/Dialogflow.Emulator.Client/Program.cs b/src/Dialogflow.Emulator.Client/Program.cs new file mode 100644 index 00000000..71d83b35 --- /dev/null +++ b/src/Dialogflow.Emulator.Client/Program.cs @@ -0,0 +1,75 @@ +using Google.Api.Gax.Grpc; +using Google.Cloud.Dialogflow.V2; +using Grpc.Core; +using Environment = System.Environment; + +// Minimal gRPC client for Dialogflow Emulator +// It sends two requests: WELCOME event and a simple text query. + +var endpoint = Environment.GetEnvironmentVariable("EMULATOR_ENDPOINT") ?? "http://localhost:7195"; +Console.WriteLine($"Using endpoint: {endpoint}"); + +// Enable HTTP/2 over plaintext for local emulator +// AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); + +var builder = new SessionsClientBuilder +{ + Endpoint = endpoint, + ChannelCredentials = ChannelCredentials.Insecure, + GrpcAdapter = GrpcNetClientAdapter.Default.WithAdditionalOptions(o => o.HttpHandler = new SocketsHttpHandler + { + UseProxy = false + }) +}; + var sessionsClient = await builder.BuildAsync(); + +var sessionId = Guid.NewGuid().ToString("N"); +var session = new SessionName("test-project", sessionId); + +// 1) WELCOME event +var welcomeRequest = new DetectIntentRequest +{ + SessionAsSessionName = session, + QueryInput = new QueryInput + { + Event = new EventInput + { + Name = "WELCOME", + LanguageCode = "ru" + } + } +}; + +Console.WriteLine("Sending WELCOME event..."); +var welcomeResponse = await sessionsClient.DetectIntentAsync(welcomeRequest); +Console.WriteLine($"Intent: {welcomeResponse.QueryResult.Intent.DisplayName}"); +Console.WriteLine($"Fulfillment: {welcomeResponse.QueryResult.FulfillmentText}"); +Console.WriteLine($"Lang: {welcomeResponse.QueryResult.LanguageCode}"); +Console.WriteLine(); + +// 2) Text query +var text = "да"; +var textRequest = new DetectIntentRequest +{ + SessionAsSessionName = session, + QueryInput = new QueryInput + { + Text = new TextInput + { + Text = text, + LanguageCode = "ru" + } + } +}; + +Console.WriteLine($"Sending text: '{text}'..."); +var textResponse = await sessionsClient.DetectIntentAsync(textRequest); +Console.WriteLine($"QueryText: {textResponse.QueryResult.QueryText}"); +Console.WriteLine($"Intent: {textResponse.QueryResult.Intent.DisplayName}"); +Console.WriteLine($"Fulfillment: {textResponse.QueryResult.FulfillmentText}"); + +// Gracefully shutdown default channels (prevents locked processes on Windows) +await SessionsClient.ShutdownDefaultChannelsAsync(); + +Console.WriteLine(); +Console.WriteLine("Done."); diff --git a/src/Dialogflow.Emulator.IntegrationTests/Dialogflow.Emulator.IntegrationTests.csproj b/src/Dialogflow.Emulator.IntegrationTests/Dialogflow.Emulator.IntegrationTests.csproj new file mode 100644 index 00000000..1623dccd --- /dev/null +++ b/src/Dialogflow.Emulator.IntegrationTests/Dialogflow.Emulator.IntegrationTests.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + true + false + enable + enable + + + + + + + + + + + + diff --git a/src/Dialogflow.Emulator.IntegrationTests/DialogflowEmulatorIntegrationTests.cs b/src/Dialogflow.Emulator.IntegrationTests/DialogflowEmulatorIntegrationTests.cs new file mode 100644 index 00000000..2217040c --- /dev/null +++ b/src/Dialogflow.Emulator.IntegrationTests/DialogflowEmulatorIntegrationTests.cs @@ -0,0 +1,130 @@ +namespace Dialogflow.Emulator.IntegrationTests; + +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Images; +using Google.Api.Gax.Grpc; +using Google.Cloud.Dialogflow.V2; +using NUnit.Framework; + +[TestFixture] +public class DialogflowEmulatorIntegrationTests +{ + private IContainer? _emulatorContainer; + private IFutureDockerImage? _emulatorImage; + private const int EmulatorPort = 8080; + private string? _emulatorEndpoint; + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + // Получаем путь к корню решения + var solutionRoot = GetSolutionRoot(); + var dialogflowPath = Path.Combine(solutionRoot, "Dialogflow", "FillInTheTextBot-test-eu"); + var dockerfileDirectory = Path.Combine(solutionRoot, "src", "Dialogflow.Emulator"); + + // Сначала собираем образ из Dockerfile + // Добавляем уникальный идентификатор к имени образа для избежания конфликтов + var imageTag = $"dialogflow-emulator-test:{Guid.NewGuid():N}"; + _emulatorImage = new ImageFromDockerfileBuilder() + .WithDockerfile("Dockerfile") + .WithDockerfileDirectory(dockerfileDirectory) + .WithContextDirectory(solutionRoot) + .WithName(imageTag) + .WithCleanUp(true) + .Build(); + + await _emulatorImage.CreateAsync().ConfigureAwait(false); + + // Создаём контейнер с эмулятором + _emulatorContainer = new ContainerBuilder() + .WithImage(_emulatorImage) + .WithPortBinding(EmulatorPort, true) + .WithEnvironment("AGENT_PATH", "/app/agent") + .WithEnvironment("Kestrel__Endpoints__Grpc__Url", "http://0.0.0.0:8080") + .WithEnvironment("Kestrel__Endpoints__Grpc__Protocols", "Http2") + .WithBindMount(dialogflowPath, "/app/agent") + .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("Now listening on")) + .Build(); + + await _emulatorContainer.StartAsync(); + + var hostPort = _emulatorContainer.GetMappedPublicPort(EmulatorPort); + _emulatorEndpoint = $"http://localhost:{hostPort}"; + } + + [OneTimeTearDown] + public async Task OneTimeTearDown() + { + if (_emulatorContainer != null) + { + await _emulatorContainer.StopAsync(); + await _emulatorContainer.DisposeAsync(); + } + + if (_emulatorImage != null) + { + await _emulatorImage.DeleteAsync().ConfigureAwait(false); + } + + // Ensure all default gRPC channels created by SessionsClient are shut down to avoid locked testhost processes. + await SessionsClient.ShutdownDefaultChannelsAsync().ConfigureAwait(false); + } + + [Test] + public async Task DetectIntent_WelcomeEvent_ReturnsWelcomeMessage() + { + // Arrange + var client = await new SessionsClientBuilder + { + Endpoint = _emulatorEndpoint, + ChannelCredentials = Grpc.Core.ChannelCredentials.Insecure, + GrpcAdapter = GrpcNetClientAdapter.Default.WithAdditionalOptions(o => o.HttpHandler = new SocketsHttpHandler + { + UseProxy = false + }) + }.BuildAsync(); + + var sessionId = Guid.NewGuid().ToString(); + var sessionName = new SessionName("test-project", sessionId); + + var request = new DetectIntentRequest + { + SessionAsSessionName = sessionName, + QueryInput = new QueryInput + { + Event = new EventInput + { + Name = "WELCOME", + LanguageCode = "ru" + } + } + }; + + // Act + var response = await client.DetectIntentAsync(request); + + // Assert + Assert.That(response, Is.Not.Null); + Assert.That(response.QueryResult, Is.Not.Null); + Assert.That(response.QueryResult.Intent.DisplayName, Is.EqualTo("Default Welcome Intent")); + Assert.That(response.QueryResult.FulfillmentText, Does.Contain("Добро пожаловать")); + Assert.That(response.QueryResult.LanguageCode, Is.EqualTo("ru")); + } + + private static string GetSolutionRoot() + { + var directory = TestContext.CurrentContext.TestDirectory; + while (directory != null && !File.Exists(Path.Combine(directory, "FillInTheTextBot.slnx"))) + { + directory = Directory.GetParent(directory)?.FullName; + } + + if (directory == null) + { + throw new InvalidOperationException("Could not find solution root directory"); + } + + return directory; + } +} diff --git a/src/Dialogflow.Emulator/Dialogflow.Emulator.csproj b/src/Dialogflow.Emulator/Dialogflow.Emulator.csproj new file mode 100644 index 00000000..49f3efc5 --- /dev/null +++ b/src/Dialogflow.Emulator/Dialogflow.Emulator.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + diff --git a/src/Dialogflow.Emulator/Dockerfile b/src/Dialogflow.Emulator/Dockerfile new file mode 100644 index 00000000..5cb39cc2 --- /dev/null +++ b/src/Dialogflow.Emulator/Dockerfile @@ -0,0 +1,27 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# Копируем файлы управления пакетами +COPY ["src/Directory.Packages.props", "src/"] +COPY ["nuget.config", "./"] + +# Копируем файлы проектов +COPY ["src/Dialogflow.Emulator/Dialogflow.Emulator.csproj", "src/Dialogflow.Emulator/"] + +# Восстанавливаем зависимости +RUN dotnet restore "src/Dialogflow.Emulator/Dialogflow.Emulator.csproj" + +# Копируем весь исходный код +COPY ["src/Dialogflow.Emulator/", "src/Dialogflow.Emulator/"] + +# Собираем проект +WORKDIR "/src/src/Dialogflow.Emulator" +RUN dotnet build "Dialogflow.Emulator.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "Dialogflow.Emulator.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Dialogflow.Emulator.dll"] diff --git a/src/Dialogflow.Emulator/Models/Intent.cs b/src/Dialogflow.Emulator/Models/Intent.cs new file mode 100644 index 00000000..51168613 --- /dev/null +++ b/src/Dialogflow.Emulator/Models/Intent.cs @@ -0,0 +1,23 @@ +namespace Dialogflow.Emulator.Models; + +using System.Text.Json.Serialization; + +public record Intent( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("responses")] IReadOnlyList Responses, + [property: JsonPropertyName("events")] IReadOnlyList? Events +); + +public record IntentResponse( + [property: JsonPropertyName("messages")] IReadOnlyList Messages +); + +public record ResponseMessage( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("speech")] IReadOnlyList? Speech +); + +public record IntentEvent( + [property: JsonPropertyName("name")] string Name +); diff --git a/src/Dialogflow.Emulator/Program.cs b/src/Dialogflow.Emulator/Program.cs new file mode 100644 index 00000000..f6d7264c --- /dev/null +++ b/src/Dialogflow.Emulator/Program.cs @@ -0,0 +1,22 @@ +using Dialogflow.Emulator.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddGrpc(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +// Initialize agent storage +var agentStorage = app.Services.GetRequiredService(); +var agentPath = builder.Configuration.GetValue("AGENT_PATH") ?? Path.Combine(Directory.GetCurrentDirectory(), "agent"); +await agentStorage.InitializeAsync(agentPath); + +// Configure the HTTP request pipeline. +app.MapGrpcService(); +app.MapGrpcService(); +app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909"); + +app.Run(); diff --git a/src/Dialogflow.Emulator/Properties/launchSettings.json b/src/Dialogflow.Emulator/Properties/launchSettings.json new file mode 100644 index 00000000..74621a35 --- /dev/null +++ b/src/Dialogflow.Emulator/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Dialogflow.Emulator": { + "commandName": "Project", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Dialogflow.Emulator/Protos/greet.proto b/src/Dialogflow.Emulator/Protos/greet.proto new file mode 100644 index 00000000..5c67cf8c --- /dev/null +++ b/src/Dialogflow.Emulator/Protos/greet.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +option csharp_namespace = "Dialogflow.Emulator"; + +package greet; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply); +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings. +message HelloReply { + string message = 1; +} diff --git a/src/Dialogflow.Emulator/Services/AgentStorage.cs b/src/Dialogflow.Emulator/Services/AgentStorage.cs new file mode 100644 index 00000000..ae9712ad --- /dev/null +++ b/src/Dialogflow.Emulator/Services/AgentStorage.cs @@ -0,0 +1,48 @@ +namespace Dialogflow.Emulator.Services; + +using System.Text.Json; +using Dialogflow.Emulator.Models; + +public class AgentStorage(ILogger logger) : IAgentStorage +{ + private Dictionary _intents = new(); + + public async Task InitializeAsync(string agentPath) + { + var intentsPath = Path.Combine(agentPath, "intents"); + if (!Directory.Exists(intentsPath)) + { + logger.LogWarning("Intents directory not found at {Path}", intentsPath); + return; + } + + var intentFiles = Directory.GetFiles(intentsPath, "*.json") + .Where(file => !file.Contains("_usersays_")); + + foreach (var file in intentFiles) + { + try + { + var json = await File.ReadAllTextAsync(file); + var intent = JsonSerializer.Deserialize(json); + if (intent != null && !string.IsNullOrEmpty(intent.Name)) + { + _intents[intent.Name] = intent; + logger.LogInformation("Loaded intent: {IntentName}", intent.Name); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load intent from {File}", file); + } + } + logger.LogInformation("Total intents loaded: {Count}", _intents.Count); + } + + public Intent? GetIntent(string name) => _intents.GetValueOrDefault(name); + + public Intent? FindIntentByEvent(string eventName) => + _intents.Values.FirstOrDefault(i => i.Events?.Any(e => e.Name == eventName) ?? false); + + public IEnumerable GetAllIntents() => _intents.Values; +} diff --git a/src/Dialogflow.Emulator/Services/DialogflowEmulatorService.cs b/src/Dialogflow.Emulator/Services/DialogflowEmulatorService.cs new file mode 100644 index 00000000..b136d9eb --- /dev/null +++ b/src/Dialogflow.Emulator/Services/DialogflowEmulatorService.cs @@ -0,0 +1,71 @@ +namespace Dialogflow.Emulator.Services; + +using Google.Cloud.Dialogflow.V2; +using Grpc.Core; +using static Google.Cloud.Dialogflow.V2.Sessions; + +public class DialogflowEmulatorService( + ILogger logger, + IAgentStorage agentStorage, + IIntentMatcher intentMatcher) : SessionsBase +{ + public override Task DetectIntent(DetectIntentRequest request, ServerCallContext context) + { + logger.LogInformation("DetectIntent request for session: {Session}", request.Session); + + Models.Intent? matchedIntent = null; + var queryText = ""; + + if (request.QueryInput.Event != null) + { + queryText = $"event:{request.QueryInput.Event.Name}"; + matchedIntent = agentStorage.FindIntentByEvent(request.QueryInput.Event.Name); + } + else if (request.QueryInput.Text != null) + { + queryText = request.QueryInput.Text.Text; + matchedIntent = intentMatcher.Match(queryText); + } + + matchedIntent ??= agentStorage.GetIntent("Default Fallback Intent"); + + var response = CreateDetectIntentResponse(matchedIntent, queryText, request.Session); + return Task.FromResult(response); + } + + private DetectIntentResponse CreateDetectIntentResponse(Models.Intent? intent, string queryText, string sessionId) + { + var fulfillmentText = "Ответ не найден."; + var textMessage = intent?.Responses.FirstOrDefault()?.Messages.FirstOrDefault(m => m.Type == "0"); + if (textMessage?.Speech?.FirstOrDefault() is { } speech) + { + fulfillmentText = speech; + } + + var queryResult = new QueryResult + { + QueryText = queryText, + FulfillmentText = fulfillmentText, + Intent = new Google.Cloud.Dialogflow.V2.Intent + { + DisplayName = intent?.Name ?? "Default Fallback Intent", + Name = $"{sessionId}/intents/{intent?.Id ?? Guid.NewGuid().ToString()}" + }, + IntentDetectionConfidence = 0.85f, + LanguageCode = "ru" + }; + queryResult.FulfillmentMessages.Add(new Intent.Types.Message + { + Text = new Intent.Types.Message.Types.Text + { + Text_ = { fulfillmentText } + } + }); + + return new DetectIntentResponse + { + ResponseId = Guid.NewGuid().ToString(), + QueryResult = queryResult + }; + } +} diff --git a/src/Dialogflow.Emulator/Services/GreeterService.cs b/src/Dialogflow.Emulator/Services/GreeterService.cs new file mode 100644 index 00000000..a01776f7 --- /dev/null +++ b/src/Dialogflow.Emulator/Services/GreeterService.cs @@ -0,0 +1,14 @@ +using Grpc.Core; + +namespace Dialogflow.Emulator.Services; + +public class GreeterService : Greeter.GreeterBase +{ + public override Task SayHello(HelloRequest request, ServerCallContext context) + { + return Task.FromResult(new HelloReply + { + Message = "Hello " + request.Name + }); + } +} diff --git a/src/Dialogflow.Emulator/Services/IAgentStorage.cs b/src/Dialogflow.Emulator/Services/IAgentStorage.cs new file mode 100644 index 00000000..4d530ac3 --- /dev/null +++ b/src/Dialogflow.Emulator/Services/IAgentStorage.cs @@ -0,0 +1,11 @@ +namespace Dialogflow.Emulator.Services; + +using Dialogflow.Emulator.Models; + +public interface IAgentStorage +{ + Task InitializeAsync(string agentPath); + Intent? GetIntent(string name); + Intent? FindIntentByEvent(string eventName); + IEnumerable GetAllIntents(); +} diff --git a/src/Dialogflow.Emulator/Services/IIntentMatcher.cs b/src/Dialogflow.Emulator/Services/IIntentMatcher.cs new file mode 100644 index 00000000..cf43fd05 --- /dev/null +++ b/src/Dialogflow.Emulator/Services/IIntentMatcher.cs @@ -0,0 +1,8 @@ +namespace Dialogflow.Emulator.Services; + +using Dialogflow.Emulator.Models; + +public interface IIntentMatcher +{ + Intent? Match(string text); +} diff --git a/src/Dialogflow.Emulator/Services/IntentMatcher.cs b/src/Dialogflow.Emulator/Services/IntentMatcher.cs new file mode 100644 index 00000000..178df518 --- /dev/null +++ b/src/Dialogflow.Emulator/Services/IntentMatcher.cs @@ -0,0 +1,39 @@ +namespace Dialogflow.Emulator.Services; + +using Dialogflow.Emulator.Models; + +public class IntentMatcher(IAgentStorage agentStorage) : IIntentMatcher +{ + private readonly Dictionary _keywordMap = new() + { + { "Default Welcome Intent", ["привет", "начать", "hello", "/start"] }, + { "EasyWelcome", ["да", "конечно", "давай"] }, + { "Exit", ["выход", "выйти", "стоп", "пока"] }, + { "Help", ["помощь", "что ты умеешь", "справка"] }, + { "TextsList", ["список текстов", "список историй", "тексты"] }, + { "Yes", ["да", "ага", "конечно", "угу"] }, + { "No", ["нет", "не хочу", "не буду"] } + }; + + public Intent? Match(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return GetFallbackIntent(); + + var lowerText = text.ToLowerInvariant().Trim(); + + foreach (var (intentName, keywords) in _keywordMap) + { + if (keywords.Any(keyword => lowerText.Contains(keyword))) + { + var intent = agentStorage.GetIntent(intentName); + if (intent != null) + return intent; + } + } + + return GetFallbackIntent(); + } + + private Intent? GetFallbackIntent() => agentStorage.GetIntent("Default Fallback Intent"); +} diff --git a/src/Dialogflow.Emulator/appsettings.Development.json b/src/Dialogflow.Emulator/appsettings.Development.json new file mode 100644 index 00000000..812494d7 --- /dev/null +++ b/src/Dialogflow.Emulator/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "AGENT_PATH": "../../Dialogflow/FillInTheTextBot-test-eu", + "Kestrel": { + "Endpoints": { + "Grpc": { + "Url": "http://127.0.0.1:7195" + } + } + } +} diff --git a/src/Dialogflow.Emulator/appsettings.json b/src/Dialogflow.Emulator/appsettings.json new file mode 100644 index 00000000..10473a70 --- /dev/null +++ b/src/Dialogflow.Emulator/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "AGENT_PATH": "", + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + }, + "Endpoints": { + "Grpc": { + "Url": "", + "Protocols": "Http2" + } + } + } +} diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props new file mode 100644 index 00000000..3684ec97 --- /dev/null +++ b/src/Directory.Packages.props @@ -0,0 +1,60 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/FillInTheTextBot.Api.IntegrationTests/FillInTheTextBot.Api.IntegrationTests.csproj b/src/FillInTheTextBot.Api.IntegrationTests/FillInTheTextBot.Api.IntegrationTests.csproj new file mode 100644 index 00000000..77d5b4da --- /dev/null +++ b/src/FillInTheTextBot.Api.IntegrationTests/FillInTheTextBot.Api.IntegrationTests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/FillInTheTextBot.Api.IntegrationTests/UnitTest1.cs b/src/FillInTheTextBot.Api.IntegrationTests/UnitTest1.cs new file mode 100644 index 00000000..0ecbe306 --- /dev/null +++ b/src/FillInTheTextBot.Api.IntegrationTests/UnitTest1.cs @@ -0,0 +1,170 @@ +using System.Net; +using System.Net.Http.Json; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Images; +using Microsoft.AspNetCore.TestHost; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FillInTheTextBot.Api.IntegrationTests; + +public class Tests +{ + private TestServer _server; + private HttpClient _client; + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + await EmulatorSetup(); + + StartFitbWithWebApplicationFactory(); + } + + private void StartFitbWithTestServer() + { + Environment.SetEnvironmentVariable("AppConfiguration__Dialogflow__EmulatorEndpoint", _emulatorEndpoint); + _server = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() + .ConfigureLogging(logging => logging.AddConsole()) + .UseEnvironment("Development") + .UseStartup(); + }) + .Build().GetTestServer(); + _client = _server.CreateClient(); + } + + private void StartFitbWithWebApplicationFactory() + { + // Environment.SetEnvironmentVariable("AppConfiguration__Dialogflow__EmulatorEndpoint", _emulatorEndpoint); + + var factory = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseEnvironment("Development"); + builder.UseSetting("AppConfiguration:Dialogflow:0:EmulatorEndpoint", _emulatorEndpoint); + }); + + + _client = factory.CreateClient(); + } + + private IContainer? _emulatorContainer; + private IFutureDockerImage? _emulatorImage; + private const int EmulatorPort = 8080; + private string? _emulatorEndpoint; + + public async Task EmulatorSetup() + { + // Получаем путь к корню решения + var solutionRoot = GetSolutionRoot(); + var dialogflowPath = Path.Combine(solutionRoot, "Dialogflow", "FillInTheTextBot-test-eu"); + var dockerfileDirectory = Path.Combine(solutionRoot, "src", "Dialogflow.Emulator"); + + // Сначала собираем образ из Dockerfile + // Добавляем уникальный идентификатор к имени образа для избежания конфликтов + var imageTag = "dialogflow-emulator-test:latest"; + _emulatorImage = new ImageFromDockerfileBuilder() + .WithDockerfile("Dockerfile") + .WithDockerfileDirectory(dockerfileDirectory) + .WithContextDirectory(solutionRoot) + .WithName(imageTag) + .Build(); + + await _emulatorImage.CreateAsync().ConfigureAwait(false); + + // Создаём контейнер с эмулятором + _emulatorContainer = new ContainerBuilder() + .WithImage(_emulatorImage) + .WithPortBinding(EmulatorPort, true) + .WithEnvironment("AGENT_PATH", "/app/agent") + .WithEnvironment("Kestrel__Endpoints__Grpc__Url", "http://0.0.0.0:8080") + .WithEnvironment("Kestrel__Endpoints__Grpc__Protocols", "Http2") + .WithBindMount(dialogflowPath, "/app/agent") + .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("Now listening on")) + .Build(); + + await _emulatorContainer.StartAsync(); + + var hostPort = _emulatorContainer.GetMappedPublicPort(EmulatorPort); + _emulatorEndpoint = $"localhost:{hostPort}"; + } + + private static string GetSolutionRoot() + { + var directory = TestContext.CurrentContext.TestDirectory; + while (directory != null && !File.Exists(Path.Combine(directory, "FillInTheTextBot.slnx"))) + { + directory = Directory.GetParent(directory)?.FullName; + } + + if (directory == null) + { + throw new InvalidOperationException("Could not find solution root directory"); + } + + return directory; + } + + [Test] + public async Task Happy_path_test() + { + var rnd = new Random(); + + var payload = new + { + meta = new + { + locale = "ru-RU", + timezone = "UTC", + client_id = $"client-{Guid.NewGuid():N}", + interfaces = new + { + screen = new { }, + payments = new { }, + account_linking = new { }, + geolocation_sharing = new { } + } + }, + session = new + { + message_id = rnd.Next(0, 1000), + session_id = Guid.NewGuid().ToString("N"), + skill_id = Guid.NewGuid().ToString("N"), + user = new { user_id = Guid.NewGuid().ToString("N") }, + application = new { application_id = Guid.NewGuid().ToString("N") }, + user_id = Guid.NewGuid().ToString("N"), + @new = true + }, + request = new + { + command = string.Empty, + original_utterance = string.Empty, + nlu = new + { + tokens = Array.Empty(), + entities = Array.Empty(), + intents = new { } + }, + markup = new { dangerous_context = false }, + type = "SimpleUtterance" + }, + state = new + { + session = new { }, + user = new { }, + application = new { } + }, + version = "1.0" + }; + + var jsonContent = JsonContent.Create(payload); + var response = await _client.PostAsync("/yandex", jsonContent); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } +} \ No newline at end of file diff --git a/src/FillInTheTextBot.Api/DI/ConfigurationRegistration.cs b/src/FillInTheTextBot.Api/DI/ConfigurationRegistration.cs index 97602b58..23249e8f 100644 --- a/src/FillInTheTextBot.Api/DI/ConfigurationRegistration.cs +++ b/src/FillInTheTextBot.Api/DI/ConfigurationRegistration.cs @@ -2,20 +2,19 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -namespace FillInTheTextBot.Api.DI +namespace FillInTheTextBot.Api.DI; + +internal static class ConfigurationRegistration { - internal static class ConfigurationRegistration + internal static void AddAppConfiguration(this IServiceCollection services, IConfiguration appConfiguration) { - internal static void AddAppConfiguration(this IServiceCollection services, IConfiguration appConfiguration) - { - var configuration = appConfiguration.GetSection($"{nameof(AppConfiguration)}").Get(); + var configuration = appConfiguration.GetSection($"{nameof(AppConfiguration)}").Get(); - services.AddSingleton(configuration); - services.AddSingleton(configuration.HttpLog); - services.AddSingleton(configuration.Redis); - services.AddSingleton(configuration.Dialogflow); - services.AddSingleton(configuration.Tracing); - services.AddSingleton(configuration.Conversation); - } + services.AddSingleton(configuration); + services.AddSingleton(configuration.HttpLog); + services.AddSingleton(configuration.Redis); + services.AddSingleton(configuration.Dialogflow); + services.AddSingleton(configuration.Tracing); + services.AddSingleton(configuration.Conversation); } -} +} \ No newline at end of file diff --git a/src/FillInTheTextBot.Api/DI/ExternalServicesRegistration.cs b/src/FillInTheTextBot.Api/DI/ExternalServicesRegistration.cs index f77a003b..be879e32 100644 --- a/src/FillInTheTextBot.Api/DI/ExternalServicesRegistration.cs +++ b/src/FillInTheTextBot.Api/DI/ExternalServicesRegistration.cs @@ -1,173 +1,183 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; +using System.Net.Http; using FillInTheTextBot.Services.Configuration; +using Google.Api.Gax.Grpc; using Google.Apis.Auth.OAuth2; using Google.Cloud.Dialogflow.V2; using GranSteL.Helpers.Redis; using GranSteL.Tools.ScopeSelector; using Grpc.Auth; -using Jaeger; -using Jaeger.Reporters; -using Jaeger.Samplers; -using Jaeger.Senders.Thrift; -using Microsoft.AspNetCore.Hosting; +using Grpc.Core; using Microsoft.Extensions.DependencyInjection; -using OpenTracing; -using OpenTracing.Util; using StackExchange.Redis; -namespace FillInTheTextBot.Api.DI +namespace FillInTheTextBot.Api.DI; + +internal static class ExternalServicesRegistration { - internal static class ExternalServicesRegistration + internal static void AddExternalServices(this IServiceCollection services) { - internal static void AddExternalServices(this IServiceCollection services) - { - services.AddSingleton(RegisterSessionsClientScopes); - services.AddSingleton(RegisterContextsClientScopes); - services.AddSingleton(RegisterRedisClient); - services.AddSingleton(RegisterTracer); - services.AddSingleton(RegisterCacheService); - } + // Регистрируем менеджер gRPC клиентов как Singleton для правильного управления жизненным циклом + services.AddSingleton(); + + services.AddSingleton(RegisterSessionsClientScopes); + services.AddSingleton(RegisterContextsClientScopes); + services.AddSingleton(RegisterRedisConnectionMultiplexer); + services.AddSingleton(RegisterRedisClient); + services.AddSingleton(RegisterCacheService); + } - private static IEnumerable GetScopesContexts(IEnumerable dialogflowConfigurations) - { - var scopeContexts = dialogflowConfigurations - .Where(configuration => !string.IsNullOrEmpty(configuration.ScopeId)) - .Select(configuration => - { - var context = new ScopeContext(configuration.ScopeId, configuration.DoNotUseForNewSessions); + private static IEnumerable GetScopesContexts( + IEnumerable dialogflowConfigurations) + { + var scopeContexts = dialogflowConfigurations + .Where(configuration => !string.IsNullOrEmpty(configuration.ScopeId)) + .Select(configuration => + { + var context = new ScopeContext(configuration.ScopeId, configuration.DoNotUseForNewSessions); - context.TryAddParameter(nameof(configuration.ProjectId), configuration.ProjectId); - context.TryAddParameter(nameof(configuration.JsonPath), configuration.JsonPath); - context.TryAddParameter(nameof(configuration.Region), configuration.Region); - context.TryAddParameter(nameof(configuration.LanguageCode), configuration.LanguageCode); - context.TryAddParameter(nameof(configuration.LogQuery), configuration.LogQuery.ToString()); + context.TryAddParameter(nameof(configuration.ProjectId), configuration.ProjectId); + context.TryAddParameter(nameof(configuration.JsonPath), configuration.JsonPath); + context.TryAddParameter(nameof(configuration.Region), configuration.Region); + context.TryAddParameter(nameof(configuration.LanguageCode), configuration.LanguageCode); + context.TryAddParameter(nameof(configuration.LogQuery), configuration.LogQuery.ToString()); + context.TryAddParameter(nameof(configuration.EmulatorEndpoint), configuration.EmulatorEndpoint); - return context; - }); + return context; + }); - return scopeContexts; - } + return scopeContexts; + } - private static ScopesSelector RegisterSessionsClientScopes(IServiceProvider provider) - { - var configuration = provider.GetService(); + private static ScopesSelector RegisterSessionsClientScopes(IServiceProvider provider) + { + var configuration = provider.GetService(); + var grpcManager = provider.GetService(); - var scopeContexts = GetScopesContexts(configuration); + var scopeContexts = GetScopesContexts(configuration); - var selector = new ScopesSelector(scopeContexts, CreateDialogflowSessionsClient); + var selector = new ScopesSelector(scopeContexts, + context => grpcManager.GetOrCreateSessionsClient(context, CreateDialogflowSessionsClient)); - return selector; - } + return selector; + } - private static SessionsClient CreateDialogflowSessionsClient(ScopeContext context) + private static SessionsClient CreateDialogflowSessionsClient(ScopeContext context) + { + context.TryGetParameterValue(nameof(DialogflowConfiguration.EmulatorEndpoint), out var emulatorEndpoint); + + if (!string.IsNullOrWhiteSpace(emulatorEndpoint)) { - context.TryGetParameterValue(nameof(DialogflowConfiguration.JsonPath), out string jsonPath); - var credential = GoogleCredential.FromFile(jsonPath).CreateScoped(SessionsClient.DefaultScopes); - - var endpoint = GetEndpoint(context, SessionsClient.DefaultEndpoint); - - var clientBuilder = new SessionsClientBuilder + // Используем gRPC эмулятор + var sessionsClientBuilder = new SessionsClientBuilder { - ChannelCredentials = credential.ToChannelCredentials(), - Endpoint = endpoint + Endpoint = emulatorEndpoint, + ChannelCredentials = ChannelCredentials.Insecure, // Для локальной отладки без TLS + GrpcAdapter = GrpcNetClientAdapter.Default.WithAdditionalOptions(o => o.HttpHandler = new SocketsHttpHandler + { + UseProxy = false + }) }; - - var client = clientBuilder.Build(); - - return client; + + return sessionsClientBuilder.Build(); } + + // Обычное подключение к Google Dialogflow + context.TryGetParameterValue(nameof(DialogflowConfiguration.JsonPath), out var jsonPath); + var credential = GoogleCredential.FromFile(jsonPath).CreateScoped(SessionsClient.DefaultScopes); + + var endpoint = GetEndpoint(context, SessionsClient.DefaultEndpoint); - private static ScopesSelector RegisterContextsClientScopes(IServiceProvider provider) + var standardClientBuilder = new SessionsClientBuilder { - var configuration = provider.GetService(); + ChannelCredentials = credential.ToChannelCredentials(), + Endpoint = endpoint + }; - var contexts = GetScopesContexts(configuration); + var standardClient = standardClientBuilder.Build(); + return standardClient; + } - var selector = new ScopesSelector(contexts, CreateDialogflowContextsClient); + private static ScopesSelector RegisterContextsClientScopes(IServiceProvider provider) + { + var configuration = provider.GetService(); + var grpcManager = provider.GetService(); - return selector; - } + var contexts = GetScopesContexts(configuration); - private static ContextsClient CreateDialogflowContextsClient(ScopeContext context) - { - context.TryGetParameterValue(nameof(DialogflowConfiguration.JsonPath), out string jsonPath); - var credential = GoogleCredential.FromFile(jsonPath).CreateScoped(ContextsClient.DefaultScopes); + var selector = new ScopesSelector(contexts, + context => grpcManager.GetOrCreateContextsClient(context, CreateDialogflowContextsClient)); - var endpoint = GetEndpoint(context, ContextsClient.DefaultEndpoint); + return selector; + } - var clientBuilder = new ContextsClientBuilder + private static ContextsClient CreateDialogflowContextsClient(ScopeContext context) + { + context.TryGetParameterValue(nameof(DialogflowConfiguration.EmulatorEndpoint), out var emulatorEndpoint); + + if (!string.IsNullOrWhiteSpace(emulatorEndpoint)) + { + // Используем gRPC эмулятор для контекстов + var contextsClientBuilder = new ContextsClientBuilder { - ChannelCredentials = credential.ToChannelCredentials(), - Endpoint = endpoint + Endpoint = emulatorEndpoint, + ChannelCredentials = ChannelCredentials.Insecure, // Для локальной отладки без TLS + GrpcAdapter = GrpcNetClientAdapter.Default.WithAdditionalOptions(o => o.HttpHandler = new SocketsHttpHandler + { + UseProxy = false + }) }; - - var client = clientBuilder.Build(); - - return client; + + return contextsClientBuilder.Build(); } + + // Обычное подключение к Google Dialogflow + context.TryGetParameterValue(nameof(DialogflowConfiguration.JsonPath), out var jsonPath); + var credential = GoogleCredential.FromFile(jsonPath).CreateScoped(ContextsClient.DefaultScopes); - private static string GetEndpoint(ScopeContext context, string defaultEndpoint) - { - context.TryGetParameterValue(nameof(DialogflowConfiguration.Region), out string region); - - if (string.IsNullOrWhiteSpace(region)) - { - return defaultEndpoint; - } + var endpoint = GetEndpoint(context, ContextsClient.DefaultEndpoint); - return $"{region}-{defaultEndpoint}"; - } - - private static IDatabase RegisterRedisClient(IServiceProvider provider) + var standardClientBuilder = new ContextsClientBuilder { - // TODO: get config as parameter - var configuration = provider.GetService(); - - var redisClient = ConnectionMultiplexer.Connect(configuration.ConnectionString); - - var dataBase = redisClient.GetDatabase(); + ChannelCredentials = credential.ToChannelCredentials(), + Endpoint = endpoint + }; - return dataBase; - } + var standardClient = standardClientBuilder.Build(); + return standardClient; + } - private static ITracer RegisterTracer(IServiceProvider provider) - { - var env = provider.GetService(); - // TODO: get config as parameter - var configuration = provider.GetService(); + private static string GetEndpoint(ScopeContext context, string defaultEndpoint) + { + context.TryGetParameterValue(nameof(DialogflowConfiguration.Region), out var region); - var serviceName = env.ApplicationName; - var fullVersion = Assembly.GetExecutingAssembly().GetName().Version; + if (string.IsNullOrWhiteSpace(region)) return defaultEndpoint; - var version = $"{fullVersion?.Major}.{fullVersion?.Minor}.{fullVersion?.Build}"; + return $"{region}-{defaultEndpoint}"; + } - var sampler = new ConstSampler(true); - var reporter = new RemoteReporter.Builder() - .WithSender(new UdpSender(configuration.Host, configuration.Port, 0)) - .Build(); + private static IConnectionMultiplexer RegisterRedisConnectionMultiplexer(IServiceProvider provider) + { + var configuration = provider.GetService(); + return ConnectionMultiplexer.Connect(configuration.ConnectionString); + } - var tracer = new Tracer.Builder(serviceName) - .WithSampler(sampler) - .WithReporter(reporter) - .WithTag("Version", version) - .Build(); + private static IDatabase RegisterRedisClient(IServiceProvider provider) + { + var redisClient = provider.GetService(); + return redisClient.GetDatabase(); + } - GlobalTracer.Register(tracer); - return tracer; - } - - private static IRedisCacheService RegisterCacheService(IServiceProvider provider) - { - var configuration = provider.GetService(); + private static IRedisCacheService RegisterCacheService(IServiceProvider provider) + { + var configuration = provider.GetService(); - var db = provider.GetService(); + var db = provider.GetService(); - var service = new RedisCacheService(db, configuration?.KeyPrefix); + var service = new RedisCacheService(db, configuration?.KeyPrefix); - return service; - } + return service; } -} +} \ No newline at end of file diff --git a/src/FillInTheTextBot.Api/DI/GrpcClientManager.cs b/src/FillInTheTextBot.Api/DI/GrpcClientManager.cs new file mode 100644 index 00000000..d9e5a84f --- /dev/null +++ b/src/FillInTheTextBot.Api/DI/GrpcClientManager.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Concurrent; +using Google.Cloud.Dialogflow.V2; +using GranSteL.Tools.ScopeSelector; +using Microsoft.Extensions.Logging; + +namespace FillInTheTextBot.Api.DI; + +/// +/// Менеджер для управления gRPC клиентами и их жизненным циклом +/// +public class GrpcClientManager : IDisposable +{ + private readonly ILogger _logger; + private readonly ConcurrentDictionary _sessionsClients = new(); + private readonly ConcurrentDictionary _contextsClients = new(); + private volatile bool _disposed; + + public GrpcClientManager(ILogger logger) + { + _logger = logger; + } + + public SessionsClient GetOrCreateSessionsClient(ScopeContext context, Func factory) + { + if (_disposed) throw new ObjectDisposedException(nameof(GrpcClientManager)); + + var key = context.ScopeId; + return _sessionsClients.GetOrAdd(key, _ => + { + _logger.LogInformation("Creating new SessionsClient for scope {ScopeId}", key); + return factory(context); + }); + } + + public ContextsClient GetOrCreateContextsClient(ScopeContext context, Func factory) + { + if (_disposed) throw new ObjectDisposedException(nameof(GrpcClientManager)); + + var key = context.ScopeId; + return _contextsClients.GetOrAdd(key, _ => + { + _logger.LogInformation("Creating new ContextsClient for scope {ScopeId}", key); + return factory(context); + }); + } + + public void Dispose() + { + if (_disposed) return; + + _logger.LogInformation("Disposing GrpcClientManager - cleaning up {SessionsCount} SessionsClients and {ContextsCount} ContextsClients", + _sessionsClients.Count, _contextsClients.Count); + + foreach (var client in _sessionsClients.Values) + { + try + { + // Попробуем вызвать Dispose или CloseAsync если доступно + if (client is IDisposable disposable) + { + disposable.Dispose(); + } + else if (client is IAsyncDisposable asyncDisposable) + { + asyncDisposable.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error disposing SessionsClient"); + } + } + + foreach (var client in _contextsClients.Values) + { + try + { + if (client is IDisposable disposable) + { + disposable.Dispose(); + } + else if (client is IAsyncDisposable asyncDisposable) + { + asyncDisposable.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error disposing ContextsClient"); + } + } + + _sessionsClients.Clear(); + _contextsClients.Clear(); + _disposed = true; + + _logger.LogInformation("GrpcClientManager disposed successfully"); + } +} \ No newline at end of file diff --git a/src/FillInTheTextBot.Api/DI/InternalServicesRegistration.cs b/src/FillInTheTextBot.Api/DI/InternalServicesRegistration.cs index 46b71334..51209709 100644 --- a/src/FillInTheTextBot.Api/DI/InternalServicesRegistration.cs +++ b/src/FillInTheTextBot.Api/DI/InternalServicesRegistration.cs @@ -1,14 +1,13 @@ using FillInTheTextBot.Services; using Microsoft.Extensions.DependencyInjection; -namespace FillInTheTextBot.Api.DI +namespace FillInTheTextBot.Api.DI; + +internal static class InternalServicesRegistration { - internal static class InternalServicesRegistration + internal static void AddInternalServices(this IServiceCollection services) { - internal static void AddInternalServices(this IServiceCollection services) - { - services.AddTransient(); - services.AddScoped(); - } + services.AddTransient(); + services.AddScoped(); } -} +} \ No newline at end of file diff --git a/src/FillInTheTextBot.Api/Dockerfile b/src/FillInTheTextBot.Api/Dockerfile index 09fc1453..ef818bf2 100644 --- a/src/FillInTheTextBot.Api/Dockerfile +++ b/src/FillInTheTextBot.Api/Dockerfile @@ -1,27 +1,3 @@ -#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. - -FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base -WORKDIR /app -EXPOSE 80 - -FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build -WORKDIR /src -COPY ["FillInTheTextBot.Api/FillInTheTextBot.Api.csproj", "FillInTheTextBot.Api/"] -COPY ["FillInTheTextBot.Services/FillInTheTextBot.Services.csproj", "FillInTheTextBot.Services/"] -COPY ["FillInTheTextBot.Models/FillInTheTextBot.Models.csproj", "FillInTheTextBot.Models/"] -COPY ["FillInTheTextBot.Messengers.Sber/FillInTheTextBot.Messengers.Sber.csproj", "FillInTheTextBot.Messengers.Sber/"] -COPY ["FillInTheTextBot.Messengers/FillInTheTextBot.Messengers.csproj", "FillInTheTextBot.Messengers/"] -COPY ["FillInTheTextBot.Messengers.Yandex/FillInTheTextBot.Messengers.Yandex.csproj", "FillInTheTextBot.Messengers.Yandex/"] -COPY ["FillInTheTextBot.Messengers.Marusia/FillInTheTextBot.Messengers.Marusia.csproj", "FillInTheTextBot.Messengers.Marusia/"] -RUN dotnet restore "FillInTheTextBot.Api/FillInTheTextBot.Api.csproj" -COPY . . -WORKDIR "/src/FillInTheTextBot.Api" -RUN dotnet build "FillInTheTextBot.Api.csproj" -c Release -o /app/build - -FROM build AS publish -RUN dotnet publish "FillInTheTextBot.Api.csproj" -c Release -o /app/publish - -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . +FROM mcr.microsoft.com/dotnet/aspnet:9.0.0-noble AS build +COPY /output . ENTRYPOINT ["dotnet", "FillInTheTextBot.Api.dll"] \ No newline at end of file diff --git a/src/FillInTheTextBot.Api/Exceptions/ExcludeBodyException.cs b/src/FillInTheTextBot.Api/Exceptions/ExcludeBodyException.cs index 65e1065b..0a146e75 100644 --- a/src/FillInTheTextBot.Api/Exceptions/ExcludeBodyException.cs +++ b/src/FillInTheTextBot.Api/Exceptions/ExcludeBodyException.cs @@ -1,25 +1,18 @@ using System; -using System.Runtime.Serialization; -namespace FillInTheTextBot.Api.Exceptions +namespace FillInTheTextBot.Api.Exceptions; + +public class ExcludeBodyException : Exception { - [Serializable] - public class ExcludeBodyException : Exception + public ExcludeBodyException() { - public ExcludeBodyException() - { - } - - public ExcludeBodyException(string message) : base(message) - { - } + } - public ExcludeBodyException(string message, Exception innerException) : base(message, innerException) - { - } + public ExcludeBodyException(string message) : base(message) + { + } - protected ExcludeBodyException(SerializationInfo info, StreamingContext context) : base(info, context) - { - } + public ExcludeBodyException(string message, Exception innerException) : base(message, innerException) + { } -} +} \ No newline at end of file diff --git a/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj b/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj index 0f9a44f5..07cb2cd7 100644 --- a/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj +++ b/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj @@ -1,37 +1,44 @@ - + - - net6.0 - Linux - 1.22.0 - Optimized interaction with Dialogflow - + + net9.0 + Linux + 1.23.0 + Updated to .NET 9.0 + - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - - - Always - - + + + Always + + + + + + diff --git a/src/FillInTheTextBot.Api/Middleware/ExceptionsMiddleware.cs b/src/FillInTheTextBot.Api/Middleware/ExceptionsMiddleware.cs index eaa9d746..b345e9b1 100644 --- a/src/FillInTheTextBot.Api/Middleware/ExceptionsMiddleware.cs +++ b/src/FillInTheTextBot.Api/Middleware/ExceptionsMiddleware.cs @@ -3,31 +3,21 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace FillInTheTextBot.Api.Middleware +namespace FillInTheTextBot.Api.Middleware; + +public class ExceptionsMiddleware(ILogger log, RequestDelegate next) { - public class ExceptionsMiddleware + public async Task InvokeAsync(HttpContext context) { - private readonly ILogger _log; - private readonly RequestDelegate _next; - - public ExceptionsMiddleware(ILogger log, RequestDelegate next) + try { - _log = log; - _next = next; + await next(context); } - - public async Task InvokeAsync(HttpContext context) + catch (Exception ex) { - try - { - await _next(context); - } - catch (Exception ex) - { - _log.LogError(ex, "Error while process request"); + log.LogError(ex, "Error while process request"); - throw; - } + throw; } } -} +} \ No newline at end of file diff --git a/src/FillInTheTextBot.Api/Middleware/MemoryMonitoringMiddleware.cs b/src/FillInTheTextBot.Api/Middleware/MemoryMonitoringMiddleware.cs new file mode 100644 index 00000000..13a73f1f --- /dev/null +++ b/src/FillInTheTextBot.Api/Middleware/MemoryMonitoringMiddleware.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using FillInTheTextBot.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace FillInTheTextBot.Api.Middleware; + +public class MemoryMonitoringMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public MemoryMonitoringMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + var endpoint = context.Request.Path.Value; + + MemoryDiagnostics.LogMemoryUsage($"Request start: {endpoint}"); + + try + { + await _next(context).ConfigureAwait(false); + } + finally + { + MemoryDiagnostics.LogMemoryUsage($"Request end: {endpoint}"); + } + } +} \ No newline at end of file diff --git a/src/FillInTheTextBot.Api/Program.cs b/src/FillInTheTextBot.Api/Program.cs index 91a5bd4e..667d5fc8 100644 --- a/src/FillInTheTextBot.Api/Program.cs +++ b/src/FillInTheTextBot.Api/Program.cs @@ -1,50 +1,37 @@ using System; using System.Collections.Generic; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using NLog.Web; using System.Linq; using System.Reflection; +using FillInTheTextBot.Api; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.ApplicationParts; +using NLog.Web; + +var builder = WebHost.CreateDefaultBuilder(args); -namespace FillInTheTextBot.Api +var hostingStartupAssemblies = builder.GetSetting(WebHostDefaults.HostingStartupAssembliesKey) ?? string.Empty; +var hostingStartupAssembliesList = hostingStartupAssemblies.Split(';'); + +var names = GetAssembliesNames(); +var fullList = hostingStartupAssembliesList.Concat(names).Distinct().ToList(); +var concatenatedNames = string.Join(';', fullList); + +var host = builder + .UseSetting(WebHostDefaults.HostingStartupAssembliesKey, concatenatedNames) + .UseStartup() + .UseNLog() + .Build(); + +host.Run(); + +static ICollection GetAssembliesNames() { - public static class Program - { - public static void Main(string[] args) - { - BuildWebHost(args).Run(); - } - - public static IWebHost BuildWebHost(string[] args) - { - var builder = WebHost.CreateDefaultBuilder(args); - - var hostingStartupAssemblies = builder.GetSetting(WebHostDefaults.HostingStartupAssembliesKey) ?? string.Empty; - var hostingStartupAssembliesList = hostingStartupAssemblies.Split(';'); - - var names = GetAssembliesNames(); - var fullList = hostingStartupAssembliesList.Concat(names).Distinct().ToList(); - var concatenatedNames = string.Join(';', fullList); - - var host = builder - .UseSetting(WebHostDefaults.HostingStartupAssembliesKey, concatenatedNames) - .UseStartup() - .UseNLog() - .Build(); - - return host; - } - - private static ICollection GetAssembliesNames() - { - var callingAssemble = Assembly.GetCallingAssembly(); - - var names = callingAssemble.GetCustomAttributes() - .Where(a => a.AssemblyName.Contains("FillInTheTextBot", StringComparison.InvariantCultureIgnoreCase)) - .Select(a => a.AssemblyName).ToList(); - - return names; - } - } -} + var callingAssemble = Assembly.GetCallingAssembly(); + + var names = callingAssemble.GetCustomAttributes() + .Where(a => a.AssemblyName.Contains("FillInTheTextBot", StringComparison.InvariantCultureIgnoreCase)) + .Select(a => a.AssemblyName).ToList(); + + return names; +} \ No newline at end of file diff --git a/src/FillInTheTextBot.Api/Properties/launchSettings.json b/src/FillInTheTextBot.Api/Properties/launchSettings.json new file mode 100644 index 00000000..3463b73d --- /dev/null +++ b/src/FillInTheTextBot.Api/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "FillInTheTextBot.Api": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://localhost:1402", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/FillInTheTextBot.Api/Startup.cs b/src/FillInTheTextBot.Api/Startup.cs index 9a99d527..c921f9e9 100644 --- a/src/FillInTheTextBot.Api/Startup.cs +++ b/src/FillInTheTextBot.Api/Startup.cs @@ -1,71 +1,126 @@ -using FillInTheTextBot.Api.Middleware; +using System; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using FillInTheTextBot.Api.DI; +using FillInTheTextBot.Api.Middleware; using FillInTheTextBot.Services; using FillInTheTextBot.Services.Configuration; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.HttpLogging; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using System; -using System.Linq; -using FillInTheTextBot.Api.DI; -using Prometheus; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace FillInTheTextBot.Api; -namespace FillInTheTextBot.Api +public class Startup { - public class Startup + private readonly IConfiguration _configuration; + + public Startup(IConfiguration configuration, ILoggerFactory loggerFactory) { - private readonly IConfiguration _configuration; + _configuration = configuration; + InternalLoggerFactory.Factory = loggerFactory; + + // Инициализируем диагностику памяти + MemoryDiagnostics.Initialize(); + + // Запускаем периодический мониторинг памяти (каждые 5 минут) + MemoryDiagnostics.StartPeriodicMemoryLogging(TimeSpan.FromMinutes(5)); + } - public Startup(IConfiguration configuration, ILoggerFactory loggerFactory) - { - _configuration = configuration; - InternalLoggerFactory.Factory = loggerFactory; - } + // This method gets called by the runtime. Use this method to add services to the container. + // ReSharper disable once UnusedMember.Global + public void ConfigureServices(IServiceCollection services) + { + services + .AddMvc() + .AddNewtonsoftJson(); - // This method gets called by the runtime. Use this method to add services to the container. - // ReSharper disable once UnusedMember.Global - public void ConfigureServices(IServiceCollection services) - { - services - .AddMvc() - .AddNewtonsoftJson(); + // Configure OpenTelemetry + var fullVersion = Assembly.GetExecutingAssembly().GetName().Version; + var version = $"{fullVersion?.Major}.{fullVersion?.Minor}.{fullVersion?.Build}"; + + var resourceBuilder = ResourceBuilder.CreateDefault() + .AddService("FillInTheTextBot", serviceVersion: version); - services.AddOpenTracing(); - services.AddHttpLogging(o => - { - o.LoggingFields = Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.All; - }); + services.AddOpenTelemetry() + .WithTracing(builder => builder + .SetResourceBuilder(resourceBuilder) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddOtlpExporter(options => + { + var tracingConfig = _configuration.GetSection("Tracing").Get(); + if (tracingConfig != null) + { + var host = string.IsNullOrEmpty(tracingConfig.Host) ? "localhost" : tracingConfig.Host; + var port = tracingConfig.Port > 0 ? tracingConfig.Port : 4317; // Стандартный порт OTLP + options.Endpoint = new Uri($"http://{host}:{port}"); + } + else + { + // Значения по умолчанию, если конфигурация не найдена + options.Endpoint = new Uri("http://localhost:4317"); + } + })) + .WithMetrics(builder => builder + .SetResourceBuilder(resourceBuilder) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter(MetricsCollector.MeterName) + .AddPrometheusExporter()); + services.AddHttpLogging(o => { o.LoggingFields = HttpLoggingFields.All; }); - services.AddAppConfiguration(_configuration); - services.AddInternalServices(); - services.AddExternalServices(); - } + services.AddAppConfiguration(_configuration); + services.AddInternalServices(); + services.AddExternalServices(); + } - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - // ReSharper disable once UnusedMember.Global - public void Configure(IApplicationBuilder app, AppConfiguration configuration) + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + // ReSharper disable once UnusedMember.Global + public void Configure(IApplicationBuilder app, AppConfiguration configuration) + { + // Регистрируем ActivityListener для нашего ActivitySource + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == "FillInTheTextBot", + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(listener); + + // Обеспечиваем освобождение listener при завершении приложения + var applicationLifetime = app.ApplicationServices.GetRequiredService(); + applicationLifetime.ApplicationStopping.Register(() => { - app.UseMiddleware(); + MemoryLeakAnalyzer.AnalyzeMemoryUsage("Application shutdown - before cleanup"); + listener?.Dispose(); + MemoryDiagnostics.ForceGarbageCollectionAndLog("Application shutdown"); + MemoryLeakAnalyzer.ForceGCAndAnalyze("Application shutdown - final analysis"); + }); - app.UseRouting(); - app.UseHttpMetrics(); - app.UseGrpcMetrics(); + app.UseMiddleware(); + + // Добавляем мониторинг памяти для каждого запроса + app.UseMiddleware(); - if (configuration.HttpLog.Enabled) - { - app.UseWhen(context => configuration.HttpLog.IncludeEndpoints.Any(w => - context.Request.Path.Value.Contains(w, StringComparison.InvariantCultureIgnoreCase)), a => - { - a.UseHttpLogging(); - }); - } + app.UseRouting(); + + if (configuration.HttpLog.Enabled) + app.UseWhen(context => configuration.HttpLog.IncludeEndpoints.Any(w => + context.Request.Path.Value.Contains(w, StringComparison.InvariantCultureIgnoreCase)), + a => { a.UseHttpLogging(); }); - app.UseEndpoints(e => - { - e.MapControllers(); - e.MapMetrics(); - }); - } + app.UseEndpoints(e => + { + e.MapControllers(); + e.MapPrometheusScrapingEndpoint(); + }); } -} +} \ No newline at end of file diff --git a/src/FillInTheTextBot.Api/appsettings.CI.json b/src/FillInTheTextBot.Api/appsettings.CI.json new file mode 100644 index 00000000..d2ae7f02 --- /dev/null +++ b/src/FillInTheTextBot.Api/appsettings.CI.json @@ -0,0 +1,15 @@ +{ + "AppConfiguration": { + "Dialogflow": [ + { + "ScopeId": "emulator", + "ProjectId": "emulator", + "LogQuery": true, + "EmulatorEndpoint": "dialogflow-emulator:8195" + } + ], + "Redis": { + "ConnectionString": "redis:6379" + } + } +} diff --git a/src/FillInTheTextBot.Api/appsettings.Development.json b/src/FillInTheTextBot.Api/appsettings.Development.json index 2d1b8f79..581b2d1f 100644 --- a/src/FillInTheTextBot.Api/appsettings.Development.json +++ b/src/FillInTheTextBot.Api/appsettings.Development.json @@ -1,10 +1,15 @@ { - "Logging": { - "IncludeScopes": false, - "LogLevel": { - "Default": "Debug", - "System": "Debug", - "Microsoft": "Debug" + "AppConfiguration": { + "Dialogflow": [ + { + "ScopeId": "emulator", + "ProjectId": "emulator", + "LogQuery": true, + "EmulatorEndpoint": "localhost:7195" + } + ], + "Redis": { + "ConnectionString": "localhost:6379" } } } diff --git a/src/FillInTheTextBot.Api/appsettings.json b/src/FillInTheTextBot.Api/appsettings.json index ec6a4edf..e218a551 100644 --- a/src/FillInTheTextBot.Api/appsettings.json +++ b/src/FillInTheTextBot.Api/appsettings.json @@ -1,4 +1,4 @@ -{ +{ "Logging": { "LogLevel": { "Default": "Debug", @@ -10,8 +10,14 @@ "HttpLog": { "Enabled": true, "AddRequestIdHeader": true, - "ExcludeBodiesWithWords": [ "ping", "pong" ], - "IncludeEndpoints": [ "sber", "marusia" ] + "ExcludeBodiesWithWords": [ + "ping", + "pong" + ], + "IncludeEndpoints": [ + "sber", + "marusia" + ] }, "Dialogflow": [ { @@ -29,15 +35,15 @@ }, "Tracing": { "Host": "", - "Port": "" + "Port": 4317 }, - "Conversation":{ + "Conversation": { "ResetContextWords": [ "другая история", "другую историю", "давай другую историю", - "помощь", - "что ты умеешь", + "помощь", + "что ты умеешь", "что ты умеешь?", "алиса, вернись", "алиса вернись", diff --git a/src/FillInTheTextBot.Api/nlog.config b/src/FillInTheTextBot.Api/nlog.config index afe87297..9a2ca57d 100644 --- a/src/FillInTheTextBot.Api/nlog.config +++ b/src/FillInTheTextBot.Api/nlog.config @@ -1,38 +1,42 @@  - - + - + - + - - - - - + + - - - - + - - + + + + - - - + + - - - + + + + + + + \ No newline at end of file diff --git a/src/FillInTheTextBot.Messengers.Marusia/FillInTheTextBot.Messengers.Marusia.csproj b/src/FillInTheTextBot.Messengers.Marusia/FillInTheTextBot.Messengers.Marusia.csproj index 6a836ac9..6e74bac1 100644 --- a/src/FillInTheTextBot.Messengers.Marusia/FillInTheTextBot.Messengers.Marusia.csproj +++ b/src/FillInTheTextBot.Messengers.Marusia/FillInTheTextBot.Messengers.Marusia.csproj @@ -1,25 +1,25 @@ - + - - net6.0 - Library - + + net9.0 + Library + - - - PreserveNewest - Always - - + + + PreserveNewest + Always + + - - - + + + - - - + + + diff --git a/src/FillInTheTextBot.Messengers.Marusia/IMarusiaService.cs b/src/FillInTheTextBot.Messengers.Marusia/IMarusiaService.cs index 48d10efc..154d2762 100644 --- a/src/FillInTheTextBot.Messengers.Marusia/IMarusiaService.cs +++ b/src/FillInTheTextBot.Messengers.Marusia/IMarusiaService.cs @@ -2,9 +2,8 @@ using MailRu.Marusia.Models; using MailRu.Marusia.Models.Input; -namespace FillInTheTextBot.Messengers.Marusia +namespace FillInTheTextBot.Messengers.Marusia; + +public interface IMarusiaService : IMessengerService { - public interface IMarusiaService : IMessengerService - { - } } \ No newline at end of file diff --git a/src/FillInTheTextBot.Messengers.Marusia/MarusiaConfiguration.cs b/src/FillInTheTextBot.Messengers.Marusia/MarusiaConfiguration.cs index 3a577d9c..74f88716 100644 --- a/src/FillInTheTextBot.Messengers.Marusia/MarusiaConfiguration.cs +++ b/src/FillInTheTextBot.Messengers.Marusia/MarusiaConfiguration.cs @@ -1,9 +1,7 @@ using FillInTheTextBot.Services.Configuration; -namespace FillInTheTextBot.Messengers.Marusia +namespace FillInTheTextBot.Messengers.Marusia; + +public class MarusiaConfiguration : MessengerConfiguration { - public class MarusiaConfiguration : MessengerConfiguration - { - - } -} +} \ No newline at end of file diff --git a/src/FillInTheTextBot.Messengers.Marusia/MarusiaController.cs b/src/FillInTheTextBot.Messengers.Marusia/MarusiaController.cs index a574f2ed..97de2e84 100644 --- a/src/FillInTheTextBot.Messengers.Marusia/MarusiaController.cs +++ b/src/FillInTheTextBot.Messengers.Marusia/MarusiaController.cs @@ -3,17 +3,17 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; -namespace FillInTheTextBot.Messengers.Marusia +namespace FillInTheTextBot.Messengers.Marusia; + +public class MarusiaController : MessengerController { - public class MarusiaController : MessengerController + public MarusiaController(ILogger log, IMarusiaService marusiaService, + MarusiaConfiguration configuration) + : base(log, marusiaService, configuration) { - public MarusiaController(ILogger log, IMarusiaService marusiaService, MarusiaConfiguration configuration) - : base(log, marusiaService, configuration) + SerializerSettings = new JsonSerializerSettings { - SerializerSettings = new JsonSerializerSettings - { - NullValueHandling = NullValueHandling.Ignore - }; - } + NullValueHandling = NullValueHandling.Ignore + }; } -} +} \ No newline at end of file diff --git a/src/FillInTheTextBot.Messengers.Marusia/MarusiaMapping.cs b/src/FillInTheTextBot.Messengers.Marusia/MarusiaMapping.cs index 847e37c5..1477a41b 100644 --- a/src/FillInTheTextBot.Messengers.Marusia/MarusiaMapping.cs +++ b/src/FillInTheTextBot.Messengers.Marusia/MarusiaMapping.cs @@ -1,102 +1,102 @@ using System; using System.Collections.Generic; using FillInTheTextBot.Models; -using MailRu.Marusia.Models; using MailRu.Marusia.Models.Buttons; using MailRu.Marusia.Models.Input; +using Button = FillInTheTextBot.Models.Button; using MarusiaModels = MailRu.Marusia.Models; -namespace FillInTheTextBot.Messengers.Marusia +namespace FillInTheTextBot.Messengers.Marusia; + +public static class MarusiaMapping { - public static class MarusiaMapping + public static Request ToRequest(this InputModel source) { - public static Models.Request ToRequest(this InputModel source) - { - if (source == null) return null; - - var destinaton = new Models.Request(); - - destinaton.ChatHash = source.Session?.SkillId; - destinaton.UserHash = source.Session?.UserId; - destinaton.Text = source.Request?.OriginalUtterance; - destinaton.SessionId = source.Session?.SessionId; - destinaton.NewSession = source.Session?.New; - destinaton.Language = source.Meta?.Locale; - destinaton.HasScreen = string.Equals(source?.Session?.Application?.ApplicationType, MarusiaModels.ApplicationTypes.Mobile); - destinaton.ClientId = source?.Meta?.ClientId; - destinaton.Source = Source.Marusia; - destinaton.Appeal = Appeal.NoOfficial; - - return destinaton; - } - - public static OutputModel FillOutput(this InputModel source, OutputModel destination) - { - if (source == null) return null; - if (destination == null) return null; - - destination.Session = source.Session; - destination.Version = source.Version; + if (source == null) return null; + + var destinaton = new Request(); + + destinaton.ChatHash = source.Session?.SkillId; + destinaton.UserHash = source.Session?.UserId; + destinaton.Text = source.Request?.OriginalUtterance; + destinaton.SessionId = source.Session?.SessionId; + destinaton.NewSession = source.Session?.New; + destinaton.Language = source.Meta?.Locale; + destinaton.HasScreen = string.Equals(source?.Session?.Application?.ApplicationType, + MarusiaModels.ApplicationTypes.Mobile); + destinaton.ClientId = source?.Meta?.ClientId; + destinaton.Source = Source.Marusia; + destinaton.Appeal = Appeal.NoOfficial; + + return destinaton; + } - return destination; - } + public static MarusiaModels.OutputModel FillOutput(this InputModel source, MarusiaModels.OutputModel destination) + { + if (source == null) return null; + if (destination == null) return null; - public static OutputModel ToOutput(this Models.Response source) - { - if (source == null) return null; + destination.Session = source.Session; + destination.Version = source.Version; - var destination = new OutputModel(); + return destination; + } - destination.Response = source.ToResponse(); - destination.Session = source.ToSession(); + public static MarusiaModels.OutputModel ToOutput(this Response source) + { + if (source == null) return null; - return destination; - } + var destination = new MarusiaModels.OutputModel(); - public static MarusiaModels.Response ToResponse(this Models.Response source) - { - if (source == null) return null; + destination.Response = source.ToResponse(); + destination.Session = source.ToSession(); - var destination = new MarusiaModels.Response(); + return destination; + } - destination.Text = source.Text?.Replace(Environment.NewLine, "\n"); - destination.Tts = source.AlternativeText?.Replace(Environment.NewLine, "\n"); - destination.EndSession = source.Finished; - destination.Buttons = source.Buttons?.ToResponseButtons(); + public static MarusiaModels.Response ToResponse(this Response source) + { + if (source == null) return null; - return destination; - } + var destination = new MarusiaModels.Response(); - public static Session ToSession(this Models.Response source) - { - if (source == null) return null; + destination.Text = source.Text?.Replace(Environment.NewLine, "\n"); + destination.Tts = source.AlternativeText?.Replace(Environment.NewLine, "\n"); + destination.EndSession = source.Finished; + destination.Buttons = source.Buttons?.ToResponseButtons(); - var destination = new Session - { - UserId = source.UserHash - }; + return destination; + } - return destination; - } + public static MarusiaModels.Session ToSession(this Response source) + { + if (source == null) return null; - public static ResponseButton[] ToResponseButtons(this ICollection source) + var destination = new MarusiaModels.Session { - if (source == null) return null; + UserId = source.UserHash + }; + + return destination; + } - var responseButtons = new List(); + public static ResponseButton[] ToResponseButtons(this ICollection