feat: add Discord NetCord gateway worker
PR Checks / test-and-build (pull_request) Successful in 5m46s
PR Checks / test-and-build (pull_request) Successful in 5m46s
Add a separate GmRelay.DiscordBot worker using NetCord Gateway with startup token validation, PostgreSQL datasource registration, slash-command setup, component interaction service registration, and lifecycle logging. Wire the Discord service through Aspire AppHost, Docker Compose, PR checks, deploy image build/push/scan/pull steps, README docs, and synchronized version 2.2.0. Add TDD coverage for project isolation, token validation, startup wiring, runtime wiring, and version synchronization. Bump version -> 2.2.0
This commit is contained in:
@@ -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
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
||||
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GmRelay.ServiceDefaults\GmRelay.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\GmRelay.Shared\GmRelay.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
Add this project to `GM-Relay.slnx` inside `/src/`:
|
||||
|
||||
```xml
|
||||
<Project Path="src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj" />
|
||||
```
|
||||
|
||||
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
|
||||
<ProjectReference Include="..\..\src\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
|
||||
```
|
||||
|
||||
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<InvalidOperationException>(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<DiscordOptions>() ?? new DiscordOptions();
|
||||
discordOptions.Validate();
|
||||
|
||||
builder.Services.AddSingleton(discordOptions);
|
||||
|
||||
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
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<DiscordGatewayLifecycleLogger> 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
|
||||
<ProjectReference Include="..\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
|
||||
```
|
||||
|
||||
Add Discord service to `src/GmRelay.AppHost/Program.cs`:
|
||||
|
||||
```csharp
|
||||
builder.AddProject<Projects.GmRelay_DiscordBot>("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("<Version>2.2.0</Version>", 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`: `<Version>2.2.0</Version>`
|
||||
- `.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`.
|
||||
Reference in New Issue
Block a user