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