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
27 KiB
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 version1.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- adddiscordproject 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- adddiscordservice and versioned image tag. - Modify:
.gitea/workflows/deploy.yml- build/push/scan/pull Discord image and includeDISCORD_BOT_TOKENin.env. - Modify:
.gitea/workflows/pr-checks.yml- build the Discord project in PR checks. - Modify:
Directory.Build.props- version2.2.0. - Modify:
src/GmRelay.Web/Components/Layout/NavMenu.razor- visible versionv2.2.0. - Generated by restore:
src/GmRelay.DiscordBot/packages.lock.json. - Generated by restore: updates to
tests/GmRelay.Bot.Tests/packages.lock.jsonandsrc/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
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:
<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/:
<Project Path="src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj" />
Create temporary minimal src/GmRelay.DiscordBot/Program.cs:
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:
<ProjectReference Include="..\..\src\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
Create tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs:
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:
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:
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:
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:
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:
[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:
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:
[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:
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:
<ProjectReference Include="..\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
Add Discord service to src/GmRelay.AppHost/Program.cs:
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
.WithReference(postgres)
.WaitFor(postgres);
Update .gitea/workflows/pr-checks.yml with:
- 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:
[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:
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.0with Russian release notes. - Close issue
#26with 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.