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..6f00daad 100644
--- a/.github/workflows/build&test.yml
+++ b/.github/workflows/build&test.yml
@@ -2,9 +2,9 @@ name: .NET
on:
push:
- branches: [ master ]
+ branches: [ main, master ]
pull_request:
- branches: [ master ]
+ branches: [ main, master ]
jobs:
build:
@@ -12,10 +12,39 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v6.0.2
+
- name: Setup .NET
- uses: actions/setup-dotnet@v1
+ uses: actions/setup-dotnet@v5.2.0
with:
- dotnet-version: 6.0.x
+ dotnet-version: 10.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@v7.1.0
+ with:
+ tags: granstel/fillinthetextbot:latest
+ load: true
+ push: false
+ context: .
+ file: src/FillInTheTextBot.Api/Dockerfile
+
+ - name: Login to Docker Hub
+ if: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') }}
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Push to hub
+ if: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') }}
+ uses: docker/build-push-action@v7.1.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/Dialogflow.Emulator.Client/Dialogflow.Emulator.Client.csproj b/src/Dialogflow.Emulator.Client/Dialogflow.Emulator.Client.csproj
new file mode 100644
index 00000000..0967e4aa
--- /dev/null
+++ b/src/Dialogflow.Emulator.Client/Dialogflow.Emulator.Client.csproj
@@ -0,0 +1,13 @@
+
+
+ Exe
+ net10.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..751953e5
--- /dev/null
+++ b/src/Dialogflow.Emulator.IntegrationTests/Dialogflow.Emulator.IntegrationTests.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.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..66409096
--- /dev/null
+++ b/src/Dialogflow.Emulator.IntegrationTests/DialogflowEmulatorIntegrationTests.cs
@@ -0,0 +1,129 @@
+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(_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..536ded57
--- /dev/null
+++ b/src/Dialogflow.Emulator/Dialogflow.Emulator.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Dialogflow.Emulator/Dockerfile b/src/Dialogflow.Emulator/Dockerfile
new file mode 100644
index 00000000..c3020f24
--- /dev/null
+++ b/src/Dialogflow.Emulator/Dockerfile
@@ -0,0 +1,27 @@
+FROM mcr.microsoft.com/dotnet/sdk:10.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:10.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..deaf4fb9
--- /dev/null
+++ b/src/Dialogflow.Emulator/Services/AgentStorage.cs
@@ -0,0 +1,49 @@
+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 => string.Equals(e.Name, eventName, StringComparison.OrdinalIgnoreCase)) ?? 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..6d5eedb9
--- /dev/null
+++ b/src/Directory.Packages.props
@@ -0,0 +1,50 @@
+
+
+ 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/HappyPathTest.cs b/src/FillInTheTextBot.Api.IntegrationTests/HappyPathTest.cs
new file mode 100644
index 00000000..961d8834
--- /dev/null
+++ b/src/FillInTheTextBot.Api.IntegrationTests/HappyPathTest.cs
@@ -0,0 +1,234 @@
+using System.Net;
+using System.Net.Http.Json;
+using DotNet.Testcontainers.Builders;
+using DotNet.Testcontainers.Containers;
+using DotNet.Testcontainers.Images;
+using Google.Cloud.Dialogflow.V2;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Logging;
+
+namespace FillInTheTextBot.Api.IntegrationTests;
+
+public class Tests
+{
+ private HttpClient? _client;
+ private WebApplicationFactory? _factory;
+
+ [OneTimeSetUp]
+ public async Task OneTimeSetUp()
+ {
+ await EmulatorSetup();
+ await RedisSetup();
+
+ StartFitbWithWebApplicationFactory();
+ }
+
+ [OneTimeTearDown]
+ public async Task OneTimeTearDown()
+ {
+ _client?.Dispose();
+ if (_factory != null)
+ {
+ await _factory.DisposeAsync();
+ }
+
+ if (_redisContainer != null)
+ {
+ await _redisContainer.StopAsync();
+ await _redisContainer.DisposeAsync();
+ }
+
+ if (_emulatorContainer != null)
+ {
+ await _emulatorContainer.StopAsync();
+ await _emulatorContainer.DisposeAsync();
+ }
+
+ if (_emulatorImage != null)
+ {
+ await _emulatorImage.DeleteAsync().ConfigureAwait(false);
+ }
+
+ await SessionsClient.ShutdownDefaultChannelsAsync().ConfigureAwait(false);
+ await ContextsClient.ShutdownDefaultChannelsAsync().ConfigureAwait(false);
+ }
+
+ private void StartFitbWithWebApplicationFactory()
+ {
+ _factory = new WebApplicationFactory().WithWebHostBuilder(builder =>
+ {
+ builder.UseEnvironment("Development");
+ builder.UseSetting("AppConfiguration:Dialogflow:0:EmulatorEndpoint", _emulatorEndpoint);
+ builder.UseSetting("AppConfiguration:Redis:ConnectionString", _redisConnectionString);
+ builder.ConfigureLogging(logging =>
+ {
+ logging.ClearProviders();
+ logging.AddConsole();
+ logging.SetMinimumLevel(LogLevel.Debug);
+ });
+ });
+
+ _client = _factory.CreateClient();
+ }
+
+ private IContainer? _emulatorContainer;
+ private IFutureDockerImage? _emulatorImage;
+ private const int EmulatorPort = 8080;
+ private string? _emulatorEndpoint;
+
+ private IContainer? _redisContainer;
+ private const int RedisPort = 6379;
+ private string? _redisConnectionString;
+
+ private async Task RedisSetup()
+ {
+ _redisContainer = new ContainerBuilder("redis:7-alpine")
+ .WithPortBinding(RedisPort, true)
+ .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("Ready to accept connections"))
+ .Build();
+
+ await _redisContainer.StartAsync();
+
+ var hostPort = _redisContainer.GetMappedPublicPort(RedisPort);
+ _redisConnectionString = $"localhost:{hostPort}";
+ }
+
+ 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:{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(_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;
+ }
+
+ private static object BuildYandexPayload(
+ string sessionId,
+ string skillId,
+ string userId,
+ string applicationId,
+ bool isNewSession,
+ string command,
+ int messageId)
+ {
+ return 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 = messageId,
+ session_id = sessionId,
+ skill_id = skillId,
+ user = new { user_id = userId },
+ application = new { application_id = applicationId },
+ user_id = userId,
+ @new = isNewSession
+ },
+ request = new
+ {
+ command,
+ original_utterance = command,
+ nlu = new
+ {
+ tokens = Array.Empty(),
+ entities = Array.Empty