diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index dcc6a56..5ce555f 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 2.1.1 + VERSION: 2.2.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) @@ -37,6 +37,20 @@ jobs: docker push git.codeanddice.ru/toutsu/gmrelay-bot:latest docker push git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }} + - name: Build Discord Bot image + run: | + docker build \ + --label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \ + -f src/GmRelay.DiscordBot/Dockerfile \ + -t git.codeanddice.ru/toutsu/gmrelay-discord-bot:latest \ + -t git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }} \ + . + + - name: Push Discord Bot image + run: | + docker push git.codeanddice.ru/toutsu/gmrelay-discord-bot:latest + docker push git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }} + - name: Build Web image run: | docker build \ @@ -68,6 +82,14 @@ jobs: --format table \ git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }} + - name: Scan Discord Bot image + run: | + trivy image \ + --severity HIGH,CRITICAL \ + --exit-code 1 \ + --format table \ + git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }} + - name: Scan Web image run: | trivy image \ @@ -88,6 +110,7 @@ jobs: run: | echo "TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}" > .env echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env + echo "DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}" >> .env echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env echo "TELEGRAM_MINI_APP_URL=${{ secrets.TELEGRAM_MINI_APP_URL }}" >> .env @@ -97,7 +120,7 @@ jobs: docker login git.codeanddice.ru/ -u toutsu -p ${{ secrets.GIT_TOKEN }} # Pull гарантирует, что мы получили нужную версию. - docker compose pull bot web + docker compose pull bot discord web # Запускаем! Флаг -d оставит их работать в фоне. docker compose up -d diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 2f06423..6cdcd2c 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -69,6 +69,9 @@ jobs: - name: Build Bot (compile check, includes SAST) run: dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore + - name: Build Discord Bot (compile check, includes SAST) + run: dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore + - name: Build Web (compile check, includes SAST) run: dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore diff --git a/Directory.Build.props b/Directory.Build.props index 9a32501..f4d5dd2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.1.1 + 2.2.0 net10.0 preview enable diff --git a/GM-Relay.slnx b/GM-Relay.slnx index 2f98987..5cdf112 100644 --- a/GM-Relay.slnx +++ b/GM-Relay.slnx @@ -2,6 +2,7 @@ + diff --git a/README.md b/README.md index 4fac48f..fcfe606 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v2.0.1`. +**Текущая версия:** `v2.2.0`. --- @@ -47,7 +47,7 @@ |---|---| | Язык | C# 14 (.NET 10) | | Архитектура | Vertical Slice + общая библиотека `GmRelay.Shared` | -| Бот | Telegram.Bot, **Native AOT** | +| Боты | Telegram.Bot (**Native AOT**), NetCord Gateway (Discord worker) | | Веб | Blazor Server | | Оркестрация | .NET Aspire (`GmRelay.AppHost`) | | БД | PostgreSQL | @@ -74,6 +74,9 @@ cp .env.example .env # Токен от @BotFather (используется ботом и как секретный ключ веб-авторизации) TELEGRAM_BOT_TOKEN=ваш_токен_здесь +# Токен Discord application bot +DISCORD_BOT_TOKEN=ваш_discord_токен_здесь + # Имя бота без @ (для Telegram Login Widget) TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь @@ -98,6 +101,7 @@ docker compose up -d - создание Docker-сети и volume PostgreSQL; - подъём PostgreSQL (`db:5432`); - запуск бота с плавной миграцией (DbUp); +- запуск отдельного Discord Gateway worker на NetCord; - запуск веб-приложения с подключением к БД и Telegram API. ### 3. Первоначальная настройка @@ -151,11 +155,10 @@ BACKUP_VOLUME_NAME=game_pgbackups ├── src/ │ ├── GmRelay.AppHost/ # .NET Aspire orchestrator │ ├── GmRelay.Bot/ # Telegram-бот (Native AOT) -│ ├── GmRelay.Migrator/ # DbUp-миграции +│ ├── GmRelay.DiscordBot/ # Discord Gateway worker на NetCord │ ├── GmRelay.ServiceDefaults/ # Aspire service defaults │ ├── GmRelay.Shared/ # Общие доменные модели -│ ├── GmRelay.Web/ # Blazor Server dashboard -│ └── GmRelay.Worker/ # Background workers +│ └── GmRelay.Web/ # Blazor Server dashboard ├── tests/ │ └── GmRelay.Bot.Tests/ # xUnit + NSubstitute ├── compose.yaml # Docker Compose (AMD64 + ARM64) diff --git a/compose.yaml b/compose.yaml index 988d092..48b7056 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.1 + image: git.codeanddice.ru/toutsu/gmrelay-bot:2.2.0 restart: always depends_on: db: @@ -66,8 +66,20 @@ services: timeout: 5s retries: 3 + discord: + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.2.0 + restart: always + depends_on: + db: + condition: service_healthy + environment: + - "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}" + - "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}" + networks: + - gmrelay + web: - image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.1 + image: git.codeanddice.ru/toutsu/gmrelay-web:2.2.0 restart: always depends_on: db: diff --git a/docs/superpowers/plans/2026-05-18-discord-netcord-gateway.md b/docs/superpowers/plans/2026-05-18-discord-netcord-gateway.md new file mode 100644 index 0000000..647bfd9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-discord-netcord-gateway.md @@ -0,0 +1,731 @@ +# Discord NetCord Gateway Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a separate `src/GmRelay.DiscordBot` worker that uses NetCord Gateway for Discord slash commands and component interactions while keeping Telegram dependencies isolated in `src/GmRelay.Bot`. + +**Architecture:** Create a new .NET worker project that references `GmRelay.ServiceDefaults` and `GmRelay.Shared`, validates `Discord:Token` during startup, registers NetCord gateway/application command/component services, and logs gateway lifecycle events through NetCord gateway handlers. Keep database connectivity aligned with the existing worker by registering the same `ConnectionStrings:gmrelaydb` `NpgsqlDataSource` pattern, but do not move Telegram code or dependencies. + +**Tech Stack:** .NET 10 worker, Aspire service defaults, NetCord.Hosting `1.0.0-alpha.489`, Npgsql `10.0.2`, xUnit, Docker Compose, Gitea Actions. + +--- + +## Issue + +- Gitea issue: `#26`, `feat: добавить src/GmRelay.DiscordBot на NetCord Gateway` +- Labels: `type:feature`, `area:discord`, `area:infra`, `platform:discord`, `priority:p1`, `pending-approval` +- Version bump: minor, `2.1.1` -> `2.2.0` +- Branch: `feature/issue-26-discord-netcord-gateway` + +## Sources Checked + +- NetCord application commands guide: `https://netcord.dev/guides/services/application-commands/introduction.html` +- NetCord intents guide: `https://netcord.dev/guides/events/intents.html` +- NetCord gateway handler docs: `https://netcord.dev/docs/NetCord.Hosting.Gateway.html` +- NuGet flat container for `NetCord.Hosting`: latest observed version `1.0.0-alpha.489` + +## File Structure + +- Create: `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` - Discord worker project and package references. +- Create: `src/GmRelay.DiscordBot/Program.cs` - host composition, token validation, database registration, NetCord service registration. +- Create: `src/GmRelay.DiscordBot/DiscordOptions.cs` - strongly typed Discord token/options validation. +- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs` - Discord-local startup redaction without referencing the Telegram worker project. +- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` - NetCord gateway lifecycle handler for ready/connect/resume/disconnect/close/rate-limit events where available. +- Create: `src/GmRelay.DiscordBot/Dockerfile` - publish and runtime image for the Discord worker. +- Modify: `GM-Relay.slnx` - include the new project. +- Modify: `src/GmRelay.AppHost/GmRelay.AppHost.csproj` - reference the Discord worker for Aspire orchestration. +- Modify: `src/GmRelay.AppHost/Program.cs` - add `discord` project with PostgreSQL reference. +- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` - reference the Discord worker project. +- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` - source-level tests for solution inclusion, Docker/Compose/CI wiring, and Telegram isolation. +- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs` - unit tests for token validation. +- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` - source-level startup tests for NetCord registration, service defaults, and PostgreSQL connection requirements. +- Modify: `compose.yaml` - add `discord` service and versioned image tag. +- Modify: `.gitea/workflows/deploy.yml` - build/push/scan/pull Discord image and include `DISCORD_BOT_TOKEN` in `.env`. +- Modify: `.gitea/workflows/pr-checks.yml` - build the Discord project in PR checks. +- Modify: `Directory.Build.props` - version `2.2.0`. +- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` - visible version `v2.2.0`. +- Generated by restore: `src/GmRelay.DiscordBot/packages.lock.json`. +- Generated by restore: updates to `tests/GmRelay.Bot.Tests/packages.lock.json` and `src/GmRelay.AppHost/packages.lock.json`. + +## TDD Plan + +### Task 1: Project Presence And Telegram Isolation + +**Files:** +- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` +- Modify: `GM-Relay.slnx` +- Create: `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` +- Create: `src/GmRelay.DiscordBot/Program.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using System; +using System.IO; + +namespace GmRelay.Bot.Tests.Discord; + +public sealed class DiscordProjectStructureTests +{ + private static string GetRepoRoot() + { + var dir = AppContext.BaseDirectory; + while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props"))) + { + dir = Directory.GetParent(dir)?.FullName; + } + + return dir ?? throw new InvalidOperationException("Could not find repo root"); + } + + [Fact] + public void Solution_ShouldIncludeDiscordWorkerProject() + { + var repoRoot = GetRepoRoot(); + var solution = File.ReadAllText(Path.Combine(repoRoot, "GM-Relay.slnx")); + + Assert.Contains("src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj", solution); + } + + [Fact] + public void DiscordWorkerProject_ShouldExistWithoutTelegramDependency() + { + var repoRoot = GetRepoRoot(); + var projectPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "GmRelay.DiscordBot.csproj"); + + Assert.True(File.Exists(projectPath), "Discord worker project should exist."); + + var project = File.ReadAllText(projectPath); + Assert.Contains("Microsoft.NET.Sdk.Worker", project); + Assert.Contains("NetCord.Hosting", project); + Assert.Contains("GmRelay.ServiceDefaults.csproj", project); + Assert.Contains("GmRelay.Shared.csproj", project); + Assert.DoesNotContain("Telegram.Bot", project); + Assert.DoesNotContain("GmRelay.Bot.csproj", project); + } + + [Fact] + public void TelegramWorkerProject_ShouldNotReferenceNetCord() + { + var repoRoot = GetRepoRoot(); + var project = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Bot", "GmRelay.Bot.csproj")); + + Assert.DoesNotContain("NetCord", project, StringComparison.OrdinalIgnoreCase); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordProjectStructureTests` + +Expected: FAIL because `GM-Relay.slnx` does not include `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` and the project file does not exist. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj`: + +```xml + + + + net10.0 + preview + enable + enable + dotnet-GmRelay.DiscordBot-issue-26 + + + + + + + + + + + + + + +``` + +Add this project to `GM-Relay.slnx` inside `/src/`: + +```xml + +``` + +Create temporary minimal `src/GmRelay.DiscordBot/Program.cs`: + +```csharp +var builder = Host.CreateApplicationBuilder(args); +builder.AddServiceDefaults(); +await builder.Build().RunAsync(); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordProjectStructureTests` + +Expected: PASS. + +### Task 2: Token Validation + +**Files:** +- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` +- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs` +- Create: `src/GmRelay.DiscordBot/DiscordOptions.cs` + +- [ ] **Step 1: Write the failing test** + +Add the project reference to `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`: + +```xml + +``` + +Create `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs`: + +```csharp +using GmRelay.DiscordBot; + +namespace GmRelay.Bot.Tests.Discord; + +public sealed class DiscordOptionsTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_ShouldRejectMissingToken(string? token) + { + var options = new DiscordOptions { Token = token }; + + var exception = Assert.Throws(options.Validate); + + Assert.Contains("Discord:Token is required", exception.Message); + Assert.Contains("Discord__Token", exception.Message); + } + + [Fact] + public void Validate_ShouldAcceptConfiguredToken() + { + var options = new DiscordOptions { Token = "configured-token" }; + + options.Validate(); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordOptionsTests` + +Expected: FAIL at compile time because `GmRelay.DiscordBot.DiscordOptions` is not defined. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/GmRelay.DiscordBot/DiscordOptions.cs`: + +```csharp +namespace GmRelay.DiscordBot; + +public sealed class DiscordOptions +{ + public string? Token { get; init; } + + public void Validate() + { + if (string.IsNullOrWhiteSpace(Token)) + { + throw new InvalidOperationException( + "Discord:Token is required. Set via environment variable Discord__Token or user secrets."); + } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordOptionsTests` + +Expected: PASS. + +### Task 3: Startup Wiring For Service Defaults, PostgreSQL, NetCord, And Slash Commands + +**Files:** +- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` +- Modify: `src/GmRelay.DiscordBot/Program.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`: + +```csharp +using System; +using System.IO; + +namespace GmRelay.Bot.Tests.Discord; + +public sealed class DiscordStartupTests +{ + private static string GetRepoRoot() + { + var dir = AppContext.BaseDirectory; + while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props"))) + { + dir = Directory.GetParent(dir)?.FullName; + } + + return dir ?? throw new InvalidOperationException("Could not find repo root"); + } + + [Fact] + public void Program_ShouldValidateDiscordTokenBeforeRunning() + { + var program = ReadProgram(); + + Assert.Contains("GetRequiredSection(\"Discord\")", program); + Assert.Contains("DiscordOptions", program); + Assert.Contains(".Validate()", program); + } + + [Fact] + public void Program_ShouldRegisterServiceDefaultsAndPostgresDataSource() + { + var program = ReadProgram(); + + Assert.Contains("builder.AddServiceDefaults()", program); + Assert.Contains("ConnectionStrings:gmrelaydb is required", program); + Assert.Contains("NpgsqlDataSource", program); + Assert.Contains("SecretRedactor.RedactConnectionString", program); + } + + [Fact] + public void Program_ShouldRegisterNetCordGatewayApplicationCommandsAndComponents() + { + var program = ReadProgram(); + + Assert.Contains(".AddDiscordGateway", program); + Assert.Contains(".AddApplicationCommands", program); + Assert.Contains(".AddComponentInteractions", program); + Assert.Contains(".AddGatewayHandlers", program); + Assert.Contains("AddSlashCommand", program); + } + + private static string ReadProgram() + { + var repoRoot = GetRepoRoot(); + return File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Program.cs")); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordStartupTests` + +Expected: FAIL because `Program.cs` does not validate `Discord:Token`, register `NpgsqlDataSource`, or register NetCord services yet. + +- [ ] **Step 3: Write minimal implementation** + +Replace `src/GmRelay.DiscordBot/Program.cs` with host composition that: + +```csharp +using GmRelay.DiscordBot; +using GmRelay.DiscordBot.Infrastructure.Logging; +using NetCord.Gateway; +using NetCord.Hosting.Gateway; +using NetCord.Hosting.Services.ApplicationCommands; +using NetCord.Hosting.Services.ComponentInteractions; +using Npgsql; + +var builder = Host.CreateApplicationBuilder(args); + +builder.AddServiceDefaults(); + +var discordOptions = builder.Configuration + .GetRequiredSection("Discord") + .Get() ?? new DiscordOptions(); +discordOptions.Validate(); + +builder.Services.AddSingleton(discordOptions); + +builder.Services.AddSingleton(sp => +{ + var config = sp.GetRequiredService(); + var loggerFactory = sp.GetRequiredService(); + var connectionString = config.GetConnectionString("gmrelaydb") + ?? throw new InvalidOperationException( + "ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb."); + + var logger = loggerFactory.CreateLogger("GmRelay.DiscordBot.Startup"); + logger.LogInformation( + "Configured PostgreSQL data source with connection string {ConnectionString}", + SecretRedactor.RedactConnectionString(connectionString)); + + return NpgsqlDataSource.Create(connectionString); +}); + +builder.Services + .AddDiscordGateway(options => + { + options.Token = discordOptions.Token; + options.Intents = GatewayIntents.Guilds; + }) + .AddApplicationCommands() + .AddComponentInteractions() + .AddGatewayHandlers(typeof(Program).Assembly); + +var host = builder.Build(); + +host.AddSlashCommand("ping", "Checks whether GM-Relay Discord is online.", () => "Pong!"); + +await host.RunAsync(); +``` + +Use the Discord-local `SecretRedactor` namespace instead of `GmRelay.Bot.Infrastructure.Logging` so the new project does not reference the Telegram worker. + +Create `src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs`: + +```csharp +using System.Text.RegularExpressions; + +namespace GmRelay.DiscordBot.Infrastructure.Logging; + +internal static partial class SecretRedactor +{ + public static string RedactConnectionString(string connectionString) + { + return PasswordPattern().Replace(connectionString, "$1***"); + } + + [GeneratedRegex(@"(?i)(Password\s*=\s*)[^;]+")] + private static partial Regex PasswordPattern(); +} +``` + +If `GatewayClientOptions.Token` does not accept `string`, adjust to NetCord's required token type after compile feedback while preserving the tests' intent. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordStartupTests` + +Expected: PASS. + +### Task 4: Gateway Lifecycle Logging + +**Files:** +- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` +- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` + +- [ ] **Step 1: Write the failing test** + +Add to `DiscordStartupTests.cs`: + +```csharp +[Fact] +public void LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues() +{ + var repoRoot = GetRepoRoot(); + var loggerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Infrastructure", "Logging", "DiscordGatewayLifecycleLogger.cs"); + + Assert.True(File.Exists(loggerPath), "Discord gateway lifecycle logger should exist."); + + var logger = File.ReadAllText(loggerPath); + Assert.Contains("IReadyGatewayHandler", logger); + Assert.Contains("IDisconnectGatewayHandler", logger); + Assert.Contains("IResumeGatewayHandler", logger); + Assert.Contains("LogInformation", logger); + Assert.DoesNotContain("Token", logger); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues` + +Expected: FAIL because `DiscordGatewayLifecycleLogger.cs` does not exist. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` using the concrete NetCord handler signatures from the installed `NetCord.Hosting` package. Minimum behavior: + +```csharp +using Microsoft.Extensions.Logging; +using NetCord.Gateway; +using NetCord.Hosting.Gateway; + +namespace GmRelay.DiscordBot.Infrastructure.Logging; + +public sealed class DiscordGatewayLifecycleLogger( + ILogger logger) + : IReadyGatewayHandler, + IDisconnectGatewayHandler, + IResumeGatewayHandler +{ + public ValueTask HandleAsync(ReadyEventArgs arg) + { + logger.LogInformation("Discord gateway ready as application {ApplicationId}", arg.Application.Id); + return ValueTask.CompletedTask; + } + + public ValueTask HandleAsync(DisconnectEventArgs arg) + { + logger.LogWarning("Discord gateway disconnected with close status {CloseStatus}", arg.CloseStatus); + return ValueTask.CompletedTask; + } + + public ValueTask HandleAsync() + { + logger.LogInformation("Discord gateway session resumed"); + return ValueTask.CompletedTask; + } +} +``` + +If interface signatures differ in `1.0.0-alpha.489`, inspect the package XML/docs and adjust the handlers to compile while keeping ready/disconnect/resume logging and never logging token values. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues` + +Expected: PASS. + +### Task 5: Runtime Container, Compose, AppHost, And CI Wiring + +**Files:** +- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` +- Create: `src/GmRelay.DiscordBot/Dockerfile` +- Modify: `compose.yaml` +- Modify: `src/GmRelay.AppHost/GmRelay.AppHost.csproj` +- Modify: `src/GmRelay.AppHost/Program.cs` +- Modify: `.gitea/workflows/pr-checks.yml` +- Modify: `.gitea/workflows/deploy.yml` + +- [ ] **Step 1: Write the failing test** + +Add to `DiscordProjectStructureTests.cs`: + +```csharp +[Fact] +public void RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram() +{ + var repoRoot = GetRepoRoot(); + var compose = File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")); + var appHostProject = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "GmRelay.AppHost.csproj")); + var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs")); + var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml")); + var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")); + + Assert.Contains("gmrelay-discord-bot:2.2.0", compose); + Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose); + Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy); + Assert.Contains("DISCORD_BOT_TOKEN", deploy); + Assert.Contains("dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore", prChecks); + Assert.Contains("GmRelay.DiscordBot.csproj", appHostProject); + Assert.Contains("Projects.GmRelay_DiscordBot", appHostProgram); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram` + +Expected: FAIL because runtime wiring is not present. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/GmRelay.DiscordBot/Dockerfile` modeled after `src/GmRelay.Bot/Dockerfile`, with project copy/restore for `GmRelay.DiscordBot`, `GmRelay.ServiceDefaults`, and `GmRelay.Shared`, and entrypoint `./GmRelay.DiscordBot`. + +Add `discord` service to `compose.yaml`: + +```yaml + discord: + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.2.0 + restart: always + depends_on: + db: + condition: service_healthy + environment: + - "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}" + - "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}" + networks: + - gmrelay +``` + +Add Discord project reference to `src/GmRelay.AppHost/GmRelay.AppHost.csproj`: + +```xml + +``` + +Add Discord service to `src/GmRelay.AppHost/Program.cs`: + +```csharp +builder.AddProject("discord") + .WithReference(postgres) + .WaitFor(postgres); +``` + +Update `.gitea/workflows/pr-checks.yml` with: + +```yaml + - name: Build Discord Bot (compile check, includes SAST) + run: dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore +``` + +Update `.gitea/workflows/deploy.yml` to build, push, scan, pull, and deploy `git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}` and write `DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}` to `.env`. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram` + +Expected: PASS. + +### Task 6: Version Synchronization + +**Files:** +- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` +- Modify: `Directory.Build.props` +- Modify: `compose.yaml` +- Modify: `.gitea/workflows/deploy.yml` +- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` + +- [ ] **Step 1: Write the failing test** + +Add to `DiscordProjectStructureTests.cs`: + +```csharp +[Fact] +public void Version_ShouldBeSynchronizedForDiscordFeatureRelease() +{ + var repoRoot = GetRepoRoot(); + + Assert.Contains("2.2.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); + Assert.Contains("VERSION: 2.2.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); + Assert.Contains("gmrelay-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-web:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-discord-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("v2.2.0", File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor"))); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter Version_ShouldBeSynchronizedForDiscordFeatureRelease` + +Expected: FAIL because current version is `2.1.1`. + +- [ ] **Step 3: Write minimal implementation** + +Update: +- `Directory.Build.props`: `2.2.0` +- `.gitea/workflows/deploy.yml`: `VERSION: 2.2.0` +- `compose.yaml`: `gmrelay-bot:2.2.0`, `gmrelay-web:2.2.0`, `gmrelay-discord-bot:2.2.0` +- `src/GmRelay.Web/Components/Layout/NavMenu.razor`: `v2.2.0` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter Version_ShouldBeSynchronizedForDiscordFeatureRelease` + +Expected: PASS. + +### Task 7: Restore, Format, Build, And Full Test Verification + +**Files:** +- Generated/updated: `src/GmRelay.DiscordBot/packages.lock.json` +- Generated/updated: `tests/GmRelay.Bot.Tests/packages.lock.json` +- Generated/updated: `src/GmRelay.AppHost/packages.lock.json` +- Any code formatting changes required by `dotnet format` + +- [ ] **Step 1: Restore lock files** + +Run: `dotnet restore GM-Relay.slnx` + +Expected: restore succeeds and creates/updates lock files for the new project references and NetCord dependency. + +- [ ] **Step 2: Run targeted tests** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~Discord` + +Expected: all Discord tests pass. + +- [ ] **Step 3: Run full tests** + +Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal` + +Expected: all tests pass. + +- [ ] **Step 4: Run release build** + +Run: `dotnet build GM-Relay.slnx -c Release` + +Expected: solution build succeeds and includes `src/GmRelay.DiscordBot`. + +- [ ] **Step 5: Run format check** + +Run: `dotnet format --verify-no-changes --verbosity diagnostic` + +Expected: no formatting changes required. + +- [ ] **Step 6: Inspect diff for secrets** + +Run: `git diff --check` + +Expected: no whitespace errors and no Discord token value in tracked files. + +Run: `git diff -- . ':!*.lock.json'` + +Expected: diff contains configuration variable names such as `Discord__Token` and `DISCORD_BOT_TOKEN`, but not a real token value. + +### Task 8: Commit, PR, CI, Deploy, Release, Issue Closure + +**Files:** +- All intended implementation, test, lock, workflow, compose, and version files. + +- [ ] **Step 1: Create commit** + +Run: + +```powershell +git status --short +git add GM-Relay.slnx Directory.Build.props compose.yaml .gitea/workflows/deploy.yml .gitea/workflows/pr-checks.yml src/GmRelay.AppHost src/GmRelay.DiscordBot src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests +git commit -m "feat: add Discord NetCord gateway worker" +``` + +Expected: only intended files are staged and committed. Do not stage untracked `CLAUDE.md`. + +- [ ] **Step 2: Push branch and open PR** + +Run: `git push -u origin feature/issue-26-discord-netcord-gateway` + +Create Gitea PR to `main` with: +- Summary of Discord worker, token validation, runtime wiring, and version bump. +- Test plan showing targeted Discord tests, full tests, release build, format, and secret diff inspection. +- Link to issue `#26`. + +- [ ] **Step 3: Store Discord token as a Gitea Actions secret** + +Use Gitea Actions configuration to create or update repository secret `DISCORD_BOT_TOKEN` with the user-provided Discord bot token. + +Expected: token is stored only as an Actions secret. The token value is not written to source files, plan files, logs, PR text, release notes, or commits. + +- [ ] **Step 4: Monitor CI** + +Use Gitea Actions run reads until PR checks finish. If CI fails, inspect logs, fix with TDD where the failure is code behavior, push again, and re-check. + +- [ ] **Step 5: Review, merge, deploy, release** + +After CI passes and review is approved: +- Merge PR. +- Monitor deploy workflow on `main`. +- Create release `v2.2.0` with Russian release notes. +- Close issue `#26` with a comment linking PR and release. + +## Self-Review + +- Spec coverage: Project creation, NetCord Gateway, slash/component service registration, `Discord__Token`, PostgreSQL service defaults, lifecycle logging, Telegram isolation, solution build, compose/deploy integration, and version sync are covered. +- Placeholder scan: No task uses `TBD`, `TODO`, or an unspecified "add tests" instruction. +- Type consistency: Test class names and file paths are consistent across tasks; NetCord lifecycle handler signatures are explicitly marked for compile-driven adjustment because the package is prerelease and must be verified against installed `1.0.0-alpha.489`. diff --git a/src/GmRelay.AppHost/GmRelay.AppHost.csproj b/src/GmRelay.AppHost/GmRelay.AppHost.csproj index 8e8f7d9..94eebb7 100644 --- a/src/GmRelay.AppHost/GmRelay.AppHost.csproj +++ b/src/GmRelay.AppHost/GmRelay.AppHost.csproj @@ -2,6 +2,7 @@ + diff --git a/src/GmRelay.AppHost/Program.cs b/src/GmRelay.AppHost/Program.cs index 64a3501..6578e71 100644 --- a/src/GmRelay.AppHost/Program.cs +++ b/src/GmRelay.AppHost/Program.cs @@ -8,6 +8,10 @@ builder.AddProject("bot") .WithReference(postgres) .WaitFor(postgres); +builder.AddProject("discord") + .WithReference(postgres) + .WaitFor(postgres); + builder.AddProject("web") .WithReference(postgres) .WaitFor(postgres); diff --git a/src/GmRelay.DiscordBot/DiscordOptions.cs b/src/GmRelay.DiscordBot/DiscordOptions.cs new file mode 100644 index 0000000..3cee6dd --- /dev/null +++ b/src/GmRelay.DiscordBot/DiscordOptions.cs @@ -0,0 +1,15 @@ +namespace GmRelay.DiscordBot; + +public sealed class DiscordOptions +{ + public string? Token { get; init; } + + public void Validate() + { + if (string.IsNullOrWhiteSpace(Token)) + { + throw new InvalidOperationException( + "Discord:Token is required. Set via environment variable Discord__Token or user secrets."); + } + } +} diff --git a/src/GmRelay.DiscordBot/Dockerfile b/src/GmRelay.DiscordBot/Dockerfile new file mode 100644 index 0000000..d075a80 --- /dev/null +++ b/src/GmRelay.DiscordBot/Dockerfile @@ -0,0 +1,20 @@ +# Stage 1: Build +FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build +WORKDIR /src + +COPY ["src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj", "src/GmRelay.DiscordBot/"] +COPY ["src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj", "src/GmRelay.ServiceDefaults/"] +COPY ["src/GmRelay.Shared/GmRelay.Shared.csproj", "src/GmRelay.Shared/"] + +RUN dotnet restore "src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj" + +COPY src/ src/ +WORKDIR /src/src/GmRelay.DiscordBot +RUN dotnet publish "GmRelay.DiscordBot.csproj" -c Release -o /app/publish /p:UseAppHost=false + +# Stage 2: Runtime +FROM mcr.microsoft.com/dotnet/runtime:10.0-noble AS final +WORKDIR /app +COPY --from=build /app/publish . +USER $APP_UID +ENTRYPOINT ["dotnet", "GmRelay.DiscordBot.dll"] diff --git a/src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj b/src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj new file mode 100644 index 0000000..59723f3 --- /dev/null +++ b/src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + preview + enable + enable + dotnet-GmRelay.DiscordBot-issue-26 + + + + + + + + + + + + + + + diff --git a/src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs b/src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs new file mode 100644 index 0000000..16a5153 --- /dev/null +++ b/src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs @@ -0,0 +1,43 @@ +using NetCord.Gateway; +using NetCord.Hosting.Gateway; + +namespace GmRelay.DiscordBot.Infrastructure.Logging; + +public sealed class DiscordGatewayLifecycleLogger( + ILogger logger) + : IConnectGatewayHandler, + IReadyGatewayHandler, + IDisconnectGatewayHandler, + IResumeGatewayHandler +{ + public ValueTask HandleAsync() + { + logger.LogInformation("Discord gateway connected"); + return ValueTask.CompletedTask; + } + + public ValueTask HandleAsync(ReadyEventArgs arg) + { + logger.LogInformation( + "Discord gateway ready for application {ApplicationId} in {GuildCount} guilds", + arg.ApplicationId, + arg.GuildIds.Count); + + return ValueTask.CompletedTask; + } + + public ValueTask HandleAsync(DisconnectEventArgs arg) + { + logger.LogWarning( + "Discord gateway disconnected; reconnect scheduled: {Reconnect}", + arg.Reconnect); + + return ValueTask.CompletedTask; + } + + ValueTask IResumeGatewayHandler.HandleAsync() + { + logger.LogInformation("Discord gateway session resumed"); + return ValueTask.CompletedTask; + } +} diff --git a/src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs b/src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs new file mode 100644 index 0000000..e46d401 --- /dev/null +++ b/src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs @@ -0,0 +1,14 @@ +using System.Text.RegularExpressions; + +namespace GmRelay.DiscordBot.Infrastructure.Logging; + +internal static partial class SecretRedactor +{ + public static string RedactConnectionString(string connectionString) + { + return PasswordPattern().Replace(connectionString, "$1***"); + } + + [GeneratedRegex(@"(?i)(Password\s*=\s*)[^;]+")] + private static partial Regex PasswordPattern(); +} diff --git a/src/GmRelay.DiscordBot/Program.cs b/src/GmRelay.DiscordBot/Program.cs new file mode 100644 index 0000000..4efe339 --- /dev/null +++ b/src/GmRelay.DiscordBot/Program.cs @@ -0,0 +1,54 @@ +using GmRelay.DiscordBot; +using GmRelay.DiscordBot.Infrastructure.Logging; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NetCord; +using NetCord.Gateway; +using NetCord.Hosting.Gateway; +using NetCord.Hosting.Services.ApplicationCommands; +using NetCord.Hosting.Services.ComponentInteractions; +using NetCord.Services.ComponentInteractions; +using Npgsql; + +var builder = Host.CreateApplicationBuilder(args); + +builder.AddServiceDefaults(); + +var discordOptions = builder.Configuration + .GetRequiredSection("Discord") + .Get() ?? new DiscordOptions(); +discordOptions.Validate(); + +builder.Services.AddSingleton(discordOptions); + +builder.Services.AddSingleton(sp => +{ + var config = sp.GetRequiredService(); + var loggerFactory = sp.GetRequiredService(); + var connectionString = config.GetConnectionString("gmrelaydb") + ?? throw new InvalidOperationException( + "ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb."); + + var logger = loggerFactory.CreateLogger("GmRelay.DiscordBot.Startup"); + logger.LogInformation( + "Configured PostgreSQL data source with connection string {ConnectionString}", + SecretRedactor.RedactConnectionString(connectionString)); + + return NpgsqlDataSource.Create(connectionString); +}); + +builder.Services + .AddDiscordGateway(options => + { + options.Token = discordOptions.Token; + options.Intents = GatewayIntents.Guilds; + }) + .AddApplicationCommands() + .AddComponentInteractions() + .AddGatewayHandlers(typeof(Program).Assembly); + +var host = builder.Build(); + +host.AddSlashCommand("ping", "Checks whether GM-Relay Discord is online.", () => "Pong!"); + +await host.RunAsync(); diff --git a/src/GmRelay.DiscordBot/packages.lock.json b/src/GmRelay.DiscordBot/packages.lock.json new file mode 100644 index 0000000..bb4d655 --- /dev/null +++ b/src/GmRelay.DiscordBot/packages.lock.json @@ -0,0 +1,666 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Aspire.Npgsql": { + "type": "Direct", + "requested": "[13.2.2, )", + "resolved": "13.2.2", + "contentHash": "nEYgziWN7hksgEQEWy24JypcMCU8gKYcIIyPL05JfdXxUWuPRLotH/KOeuHevAjSEOYkL3dtGakBkJAuPobGmA==", + "dependencies": { + "AspNetCore.HealthChecks.NpgSql": "9.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.Binder": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Diagnostics.HealthChecks": "10.0.5", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5", + "Npgsql.DependencyInjection": "10.0.1", + "Npgsql.OpenTelemetry": "10.0.1", + "OpenTelemetry.Extensions.Hosting": "1.15.0" + } + }, + "Microsoft.Extensions.Hosting": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "8i7e5IBdiKLNqt/+ciWrS8U95Rv5DClaaj7ulkZbimnCi4uREWd+lXzkp3joofFuIPOlAzV4AckxLTIELv2jdg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.Binder": "10.0.5", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.5", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.5", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.5", + "Microsoft.Extensions.Configuration.Json": "10.0.5", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.5", + "Microsoft.Extensions.DependencyInjection": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Diagnostics": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Physical": "10.0.5", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Configuration": "10.0.5", + "Microsoft.Extensions.Logging.Console": "10.0.5", + "Microsoft.Extensions.Logging.Debug": "10.0.5", + "Microsoft.Extensions.Logging.EventLog": "10.0.5", + "Microsoft.Extensions.Logging.EventSource": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "NetCord.Hosting": { + "type": "Direct", + "requested": "[1.0.0-alpha.489, )", + "resolved": "1.0.0-alpha.489", + "contentHash": "yQcvgY3uu98ndoLXpiFhJ5kungoWVLd7xnO18GmukRPVsRzyOKgxe/Ycp8DLYTtiQG9Wyg1pV4Iv6rvo+zck4w==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.8", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.8", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8", + "Microsoft.Extensions.Options.DataAnnotations": "10.0.8", + "NetCord": "1.0.0-alpha.489" + } + }, + "NetCord.Hosting.Services": { + "type": "Direct", + "requested": "[1.0.0-alpha.489, )", + "resolved": "1.0.0-alpha.489", + "contentHash": "Md46+zLB9UWYLM7PVlATytkjAC9602wBNKO7m5eaBiDdEvZOPsUrR6NJJr2YtJoKjttbvhte5ayDXj8WGGsevQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.8", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.8", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8", + "Microsoft.Extensions.Options.DataAnnotations": "10.0.8", + "NetCord.Hosting": "1.0.0-alpha.489", + "NetCord.Services": "1.0.0-alpha.489" + } + }, + "Npgsql": { + "type": "Direct", + "requested": "[10.0.2, )", + "resolved": "10.0.2", + "contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "SecurityCodeScan.VS2019": { + "type": "Direct", + "requested": "[5.6.7, )", + "resolved": "5.6.7", + "contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ==" + }, + "AspNetCore.HealthChecks.NpgSql": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11", + "Npgsql": "8.0.3" + } + }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "10.2.0", + "contentHash": "CNrEjaOCZ8d1HtB0mvpiX4EWxLkee2xy+CsYXxmsEYJSFgw3OmF9pIhP/tCTeYBHhpsKJj5wM63G8IBFGxAcsw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.2", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.2", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.2" + } + }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.2.0", + "contentHash": "1a4xDAT6fRyP8t419q3WvWMmMslDTvI7OAZLWBhn5rysFG0bl5xFenTswd1xAbT/3u3mx4Xyb5bPx+V+18tJeQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2", + "Microsoft.Extensions.ObjectPool": "10.0.2" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "or9fOLopMUTJOQVJ3bou4aD6PwvsiKf4kZC4EE5sRRKSkmh+wfk/LekJXRjAX88X+1JA9zHjDo+5fiQ7z3MY/A==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "tchMGQ+zVTO40np/Zzg2Li/TIR8bksQgg4UVXZa0OzeFCKWnIYtxE2FVs+eSmjPGCjMS2voZbwN/mUcYfpSTuA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "OhTr0O79dP49734lLTqVveivVX9sDXxbI/8vjELAZTHXqoN90mdpgTAgwicJED42iaHMCcZcK6Bj+8wNyBikaw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Physical": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "brBM/WP0YAUYh2+QqSYVdK8eQHYQTtTEUJXJ+84Zkdo2buGLja9VSrMIhgoeBUU7JBmcskAib8Lb/N83bvxgYQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "fhdG6UV9lIp70QhNkVyaHciUVq25IPFkczheVJL9bIFvmnJ+Zghaie6dWkDbbVmxZlHl9gj3zTDxMxJs5zNhIA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.Json": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Physical": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "10.2.0", + "contentHash": "Z/OI261l7LnxyODKPx0trQyIHFyicCR/akfn64lGOjPcf4FpAZ7ePAGl2HPvQBUBSNfPTF0gWeCfuFmyftMgYA==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.2" + } + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "vAJHd4yOpmKoK+jBuYV7a3y+Ab9U4ARCc29b6qvMy276RgJFw9LFs0DdsPqOL3ahwzyrX7tM+i4cCxU/RX0qAg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "10.2.0", + "contentHash": "3qMK1D40D10kb5TdBtFJpzz6/WH0NinWs68ZZS8jCFgHMXDiOjGiPOneMmIocCP/wnUUW4Hzf8lMsIE1xIGxDA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "REdt95QXHscGdtw/UUgyCW2lF9DJcAOJxmebKW2IkgUjuCAdMODIi2HNOWg5utW98nm8ekgV0Gjqs/sljwwqMw==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.5", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "NrIMTy7dpqxAvA6kHAYH8cXID/YgeNOy0OqFKpLtkPu5X4WS/basX91UszANzVrMNRAICJ2GOnGiRxJtsRyEQw==" + }, + "Microsoft.Extensions.Features": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "X7tm2aV2w3lN9roSSGhl19lz4w76HvdiuKNhIv2XOiorYII9XCm66o/z9IJ0+QwkgvEv5gMZDM6rV6uwABHEQQ==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "U+oquaPxFdY8lYeEIWO/AD7jDIl9sPW6aVWMQRHU/pZ/SWpLcOrAj2fcLe1HwXl4sYw1ONI56K/eELT3xr4RRQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "dMu5kUPSfol1Rqhmr6nWPSmbFjDe9w6bkoKithG17bWTZA0UyKirTatM5mqYUN3mGpNA0MorlusIoVTh6J7o5g==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "mOE3ARusNQR0a5x8YOcnUbfyyXGqoAWQtEc7qFOfNJgruDWQLo39Re+3/Lzj5pLPFuFYj8hN4dgKzaSQDKiOCw==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "MoOWFPT88/pDfmWpbU9PydKRX/rJFQkliowE/L9wbQcl94IicUphb5BFgepkWiDkYYxPnuEqjN4buzOGW4vJpQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "egUPC0xydb1ugCMcRyJ6zaOGOzx7N4coOVlGeLcIsXhUf1xHHwZeX+ob7JuG0dXExFduHYE/t+4/4y8BLlBKmw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.2", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2", + "Microsoft.Extensions.Diagnostics": "10.0.2", + "Microsoft.Extensions.Logging": "10.0.2", + "Microsoft.Extensions.Logging.Abstractions": "10.0.2", + "Microsoft.Extensions.Options": "10.0.2" + } + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "10.2.0", + "contentHash": "I0FBgF6yZRwYH9E3KQ2vHm80YZ7YBj+52GDsmOWXPBv/p15b/wUoNupV9kw3LnSNVsWMqlGbiuZgBnHpMwPh+Q==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.2", + "Microsoft.Extensions.Telemetry": "10.2.0" + } + }, + "Microsoft.Extensions.Http.Resilience": { + "type": "Transitive", + "resolved": "10.2.0", + "contentHash": "Lg+OjBW+ODDbM4Ax4LoERvQ1dqSZ8I2gQc2+B0/WOWl2+PunLJ3xb3x8MtHGfcb/Mp98RoMpwRKm6Aj9mzXwrA==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.2.0", + "Microsoft.Extensions.ObjectPool": "10.0.2", + "Microsoft.Extensions.Resilience": "10.2.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "cSgxsDgfP0+gmVRPVoNHI/KIDavIZxh+CxE6tSLPlYTogqccDnjBFI9CgEsiNuMP6+fiuXUwhhlTz36uUEpwbQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.Binder": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "PMs2gha2v24hvH5o5KQem5aNK4mN0BhhCWlMqsg9tzifWKzjeQi2tyPOP/RaWMVvalOhVLcrmoMYPqbnia/epg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Configuration": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "/VacEkBQ02A8PBXSa6YpbIXCuisYy6JJr62/+ANJDZE+RMBfZMcXJXLfr/LpyLE6pgdp17Wxlt7e7R9zvkwZ3Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "0ezhWYJS4/6KrqQel9JL+Tr4n+4EX2TF5EYiaysBWNNEM2c3Gtj1moD39esfgk8OHblSX+UFjtZ3z0c4i9tRvw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "System.Diagnostics.EventLog": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "vN+aq1hBFXyYvY5Ow9WyeR66drKQxRZmas4lAjh6QWfryPkjTn1uLtX5AFIxyDaZj78v5TG2sELUyvrXpAPQQw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "kpCp4m7nwJVBcRKWXYHdVK/W0dkKyyFOjCmKVdO+zKThWvUxP1V+jVEP9FGpqRu4GPl9041SEXu2f+U/l825nQ==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Configuration.Binder": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Options.DataAnnotations": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "HhxwIGECGGJ8ox2kvm6/hkN/w1ZyKrO5uu/rLAL51V0ypPdahoNf+dHS6Er/DJs2aeUmH38ZTTzACfLy1O6w3Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "10.2.0", + "contentHash": "v4WOdAOFxB3AcsUkZWNcHL3mYzs4KAPtHO8rkoQlFKOBoD3KyjjAL+h3tRwSK5i4UpF/yhxsQRY0JxKj4osxxw==", + "dependencies": { + "Microsoft.Extensions.Diagnostics": "10.0.2", + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.2.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.2", + "Microsoft.Extensions.Telemetry.Abstractions": "10.2.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.ServiceDiscovery": { + "type": "Transitive", + "resolved": "10.2.0", + "contentHash": "AHTPfiKodj66xA8RwRkFD4q11V2AvzcuDsujv6ViPkOPtvBEYcPVplHakK56pPzWlX08MDS+TAQXfFXAeP7J5w==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.2", + "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.2.0" + } + }, + "Microsoft.Extensions.ServiceDiscovery.Abstractions": { + "type": "Transitive", + "resolved": "10.2.0", + "contentHash": "sANlOvfqfw/yfych4CLlHSKSWzIie6mQG7w83gVur1foNOafyHxcgpoQMvBf+KiB4Tpls6P1/Z77IIQSK8hxFg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.2", + "Microsoft.Extensions.Configuration.Binder": "10.0.2", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2", + "Microsoft.Extensions.Features": "10.0.2", + "Microsoft.Extensions.Logging.Abstractions": "10.0.2", + "Microsoft.Extensions.Options": "10.0.2", + "Microsoft.Extensions.Primitives": "10.0.2" + } + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "10.2.0", + "contentHash": "ssW5gosYlewNH/ISTyaLD/XfJT4GSjwShOUKv61fpXrqVmHkhuIA/5bBAGStM1XbzJjt9IG2vzfdHTu4zlX9Ew==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "10.2.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.2.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.2", + "Microsoft.Extensions.ObjectPool": "10.0.2", + "Microsoft.Extensions.Telemetry.Abstractions": "10.2.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.2.0", + "contentHash": "6V4V6NX6RLUYWwV89DeW/4zK5xOycYHWhsfMXSpKVGgMHfXcczmbk6hBeqTnRPzhpATYcOWlmA6hk1jgdxUugA==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.2.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.2", + "Microsoft.Extensions.ObjectPool": "10.0.2", + "Microsoft.Extensions.Options": "10.0.2" + } + }, + "NetCord": { + "type": "Transitive", + "resolved": "1.0.0-alpha.489", + "contentHash": "/rM73l1pwwJCWHi7YrIiSVc+GVL0lV+k+amqNJUMINjLO+c5bKWj9PoNNoMhiPZoaORO4k6Uxp8EQfoQj3AYtA==" + }, + "NetCord.Services": { + "type": "Transitive", + "resolved": "1.0.0-alpha.489", + "contentHash": "SwG/7Khba1uRENDvG22RV/POByIwh/ZrenMrSzwoEcEYPMI5TabmEEB3ySH15XGdLcFZJEj106AlriN0kZhfFg==", + "dependencies": { + "NetCord": "1.0.0-alpha.489" + } + }, + "Npgsql.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "YHFa4vD27sNIfv6s5q8Zi1fLvKfmK1xcpMv0PUvXOxDFbRmuMRSHwpZTbPvsAlj97q1/o7DfyynLqfqrCm1VnA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Npgsql": "10.0.1" + } + }, + "Npgsql.OpenTelemetry": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "G9fEIBaHggZXWfDSDnKLc0XwKcbuU6i2eXp7zDqpgYxbhCmIN9fRgaSOGyyMNHSo/yY1IB4G4CjW5VO/SKRR0g==", + "dependencies": { + "Npgsql": "10.0.1", + "OpenTelemetry.API": "1.14.0" + } + }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "N0i6WjPoHPbZyms1ugbDIFAJFuGlpeExJMU/+XSL0lQRUkg/D0utFkDoLXf8Z1km5B+xVZ2GyMXXiX8qdeNmPg==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.3" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "OpenTelemetry.Api": "1.15.3" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==", + "dependencies": { + "OpenTelemetry": "1.15.3" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "u8n/W8yIlqv0BXZmvId1iVaeWXG42tGKdTkuLYg5g57Y/r9CeUNzqtrSHNdG5IoO8iPX79w3v+WsbAHgUQbfeg==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "OpenTelemetry": "1.15.3" + } + }, + "OpenTelemetry.Instrumentation.AspNetCore": { + "type": "Transitive", + "resolved": "1.15.2", + "contentHash": "2nPd7r0ug/gd6/CNFL6Rlu+RSQ9WYGSGHAYQ1ssbSqyzKJpqTunfx2I/1O0WB5k+L0cyXbG4XVZpoSoUc3M7wg==", + "dependencies": { + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)" + } + }, + "OpenTelemetry.Instrumentation.Http": { + "type": "Transitive", + "resolved": "1.15.1", + "contentHash": "vFO4Fj/dXkoVNGo/nhoGpO2zYQmZwr4jTID7oRGo+XlQ8LqksyZjUXQ4p39RfUvTID7IzzL8Qe71tW7CcAFymA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)" + } + }, + "OpenTelemetry.Instrumentation.Runtime": { + "type": "Transitive", + "resolved": "1.15.1", + "contentHash": "cpPwlUT5HXcLGPaIgsbSy0W9eFYAPGVbTP1p8/uyQ4Osvf5BJuPpEXE7crL09SmEd44r0DGNKDtsqxaAz0HxQw==", + "dependencies": { + "OpenTelemetry.Api": "[1.15.3, 2.0.0)" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" + }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2", + "System.Threading.RateLimiting": "8.0.0" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "wugvy+pBVzjQEnRs9wMTWwoaeNFX3hsaHeVHFDIvJSWXp7wfmNWu3mxAwBIE6pyW+g6+rHa1Of5fTzb0QVqUTA==" + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, + "gmrelay.servicedefaults": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Http.Resilience": "[10.2.0, )", + "Microsoft.Extensions.ServiceDiscovery": "[10.2.0, )", + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", + "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", + "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )", + "OpenTelemetry.Instrumentation.Http": "[1.15.1, )", + "OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )" + } + }, + "gmrelay.shared": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 55cf503..6cf09e9 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -56,7 +56,7 @@ - + diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs new file mode 100644 index 0000000..2b60538 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs @@ -0,0 +1,28 @@ +using GmRelay.DiscordBot; + +namespace GmRelay.Bot.Tests.Discord; + +public sealed class DiscordOptionsTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_ShouldRejectMissingToken(string? token) + { + var options = new DiscordOptions { Token = token }; + + var exception = Assert.Throws(options.Validate); + + Assert.Contains("Discord:Token is required", exception.Message); + Assert.Contains("Discord__Token", exception.Message); + } + + [Fact] + public void Validate_ShouldAcceptConfiguredToken() + { + var options = new DiscordOptions { Token = "configured-token" }; + + options.Validate(); + } +} diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs new file mode 100644 index 0000000..7f98738 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; + +namespace GmRelay.Bot.Tests.Discord; + +public sealed class DiscordProjectStructureTests +{ + private static string GetRepoRoot() + { + var dir = AppContext.BaseDirectory; + while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props"))) + { + dir = Directory.GetParent(dir)?.FullName; + } + + return dir ?? throw new InvalidOperationException("Could not find repo root"); + } + + [Fact] + public void Solution_ShouldIncludeDiscordWorkerProject() + { + var repoRoot = GetRepoRoot(); + var solution = File.ReadAllText(Path.Combine(repoRoot, "GM-Relay.slnx")); + + Assert.Contains("src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj", solution); + } + + [Fact] + public void DiscordWorkerProject_ShouldExistWithoutTelegramDependency() + { + var repoRoot = GetRepoRoot(); + var projectPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "GmRelay.DiscordBot.csproj"); + + Assert.True(File.Exists(projectPath), "Discord worker project should exist."); + + var project = File.ReadAllText(projectPath); + Assert.Contains("Microsoft.NET.Sdk.Worker", project); + Assert.Contains("NetCord.Hosting", project); + Assert.Contains("GmRelay.ServiceDefaults.csproj", project); + Assert.Contains("GmRelay.Shared.csproj", project); + Assert.DoesNotContain("Telegram.Bot", project); + Assert.DoesNotContain("GmRelay.Bot.csproj", project); + } + + [Fact] + public void TelegramWorkerProject_ShouldNotReferenceNetCord() + { + var repoRoot = GetRepoRoot(); + var project = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Bot", "GmRelay.Bot.csproj")); + + Assert.DoesNotContain("NetCord", project, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram() + { + var repoRoot = GetRepoRoot(); + var compose = File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")); + var appHostProject = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "GmRelay.AppHost.csproj")); + var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs")); + var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml")); + var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")); + + Assert.Contains("gmrelay-discord-bot:2.2.0", compose); + Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose); + Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy); + Assert.Contains("DISCORD_BOT_TOKEN", deploy); + Assert.Contains("dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore", prChecks); + Assert.Contains("GmRelay.DiscordBot.csproj", appHostProject); + Assert.Contains("Projects.GmRelay_DiscordBot", appHostProgram); + } + + [Fact] + public void Version_ShouldBeSynchronizedForDiscordFeatureRelease() + { + var repoRoot = GetRepoRoot(); + + Assert.Contains("2.2.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); + Assert.Contains("VERSION: 2.2.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); + Assert.Contains("gmrelay-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-web:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-discord-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains( + "v2.2.0", + File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor"))); + } +} diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs new file mode 100644 index 0000000..2d83a2e --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; + +namespace GmRelay.Bot.Tests.Discord; + +public sealed class DiscordStartupTests +{ + private static string GetRepoRoot() + { + var dir = AppContext.BaseDirectory; + while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props"))) + { + dir = Directory.GetParent(dir)?.FullName; + } + + return dir ?? throw new InvalidOperationException("Could not find repo root"); + } + + [Fact] + public void Program_ShouldValidateDiscordTokenBeforeRunning() + { + var program = ReadProgram(); + + Assert.Contains("GetRequiredSection(\"Discord\")", program); + Assert.Contains("DiscordOptions", program); + Assert.Contains(".Validate()", program); + } + + [Fact] + public void Program_ShouldRegisterServiceDefaultsAndPostgresDataSource() + { + var program = ReadProgram(); + + Assert.Contains("builder.AddServiceDefaults()", program); + Assert.Contains("ConnectionStrings:gmrelaydb is required", program); + Assert.Contains("NpgsqlDataSource", program); + Assert.Contains("SecretRedactor.RedactConnectionString", program); + } + + [Fact] + public void Program_ShouldRegisterNetCordGatewayApplicationCommandsAndComponents() + { + var program = ReadProgram(); + + Assert.Contains(".AddDiscordGateway", program); + Assert.Contains(".AddApplicationCommands", program); + Assert.Contains(".AddComponentInteractions", program); + Assert.Contains(".AddGatewayHandlers", program); + Assert.Contains("AddSlashCommand", program); + } + + [Fact] + public void LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues() + { + var repoRoot = GetRepoRoot(); + var loggerPath = Path.Combine( + repoRoot, + "src", + "GmRelay.DiscordBot", + "Infrastructure", + "Logging", + "DiscordGatewayLifecycleLogger.cs"); + + Assert.True(File.Exists(loggerPath), "Discord gateway lifecycle logger should exist."); + + var logger = File.ReadAllText(loggerPath); + Assert.Contains("IReadyGatewayHandler", logger); + Assert.Contains("IDisconnectGatewayHandler", logger); + Assert.Contains("IResumeGatewayHandler", logger); + Assert.Contains("LogInformation", logger); + Assert.DoesNotContain("Token", logger); + } + + private static string ReadProgram() + { + var repoRoot = GetRepoRoot(); + return File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Program.cs")); + } +} diff --git a/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj b/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj index f89f736..f88c60e 100644 --- a/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj +++ b/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj @@ -25,6 +25,7 @@ + diff --git a/tests/GmRelay.Bot.Tests/packages.lock.json b/tests/GmRelay.Bot.Tests/packages.lock.json index d5d4454..069ccdc 100644 --- a/tests/GmRelay.Bot.Tests/packages.lock.json +++ b/tests/GmRelay.Bot.Tests/packages.lock.json @@ -202,6 +202,36 @@ "Newtonsoft.Json": "13.0.3" } }, + "NetCord": { + "type": "Transitive", + "resolved": "1.0.0-alpha.489", + "contentHash": "/rM73l1pwwJCWHi7YrIiSVc+GVL0lV+k+amqNJUMINjLO+c5bKWj9PoNNoMhiPZoaORO4k6Uxp8EQfoQj3AYtA==" + }, + "NetCord.Hosting": { + "type": "Transitive", + "resolved": "1.0.0-alpha.489", + "contentHash": "yQcvgY3uu98ndoLXpiFhJ5kungoWVLd7xnO18GmukRPVsRzyOKgxe/Ycp8DLYTtiQG9Wyg1pV4Iv6rvo+zck4w==", + "dependencies": { + "NetCord": "1.0.0-alpha.489" + } + }, + "NetCord.Hosting.Services": { + "type": "Transitive", + "resolved": "1.0.0-alpha.489", + "contentHash": "Md46+zLB9UWYLM7PVlATytkjAC9602wBNKO7m5eaBiDdEvZOPsUrR6NJJr2YtJoKjttbvhte5ayDXj8WGGsevQ==", + "dependencies": { + "NetCord.Hosting": "1.0.0-alpha.489", + "NetCord.Services": "1.0.0-alpha.489" + } + }, + "NetCord.Services": { + "type": "Transitive", + "resolved": "1.0.0-alpha.489", + "contentHash": "SwG/7Khba1uRENDvG22RV/POByIwh/ZrenMrSzwoEcEYPMI5TabmEEB3ySH15XGdLcFZJEj106AlriN0kZhfFg==", + "dependencies": { + "NetCord": "1.0.0-alpha.489" + } + }, "Newtonsoft.Json": { "type": "Transitive", "resolved": "13.0.3", @@ -362,13 +392,24 @@ "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", "Dapper.AOT": "[1.0.48, )", - "GmRelay.ServiceDefaults": "[1.15.1, )", - "GmRelay.Shared": "[1.15.1, )", + "GmRelay.ServiceDefaults": "[2.1.1, )", + "GmRelay.Shared": "[2.1.1, )", "Npgsql": "[10.0.2, )", "Telegram.Bot": "[22.9.5.3, )", "dbup-postgresql": "[7.0.1, )" } }, + "gmrelay.discordbot": { + "type": "Project", + "dependencies": { + "Aspire.Npgsql": "[13.2.2, )", + "GmRelay.ServiceDefaults": "[2.1.1, )", + "GmRelay.Shared": "[2.1.1, )", + "NetCord.Hosting": "[1.0.0-alpha.489, )", + "NetCord.Hosting.Services": "[1.0.0-alpha.489, )", + "Npgsql": "[10.0.2, )" + } + }, "gmrelay.servicedefaults": { "type": "Project", "dependencies": { @@ -389,8 +430,8 @@ "dependencies": { "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", - "GmRelay.ServiceDefaults": "[1.15.1, )", - "GmRelay.Shared": "[1.15.1, )", + "GmRelay.ServiceDefaults": "[2.1.1, )", + "GmRelay.Shared": "[2.1.1, )", "Npgsql": "[10.0.2, )", "Telegram.Bot": "[22.9.6.1, )" }