Files
GmRelayBot/docs/superpowers/plans/2026-05-18-discord-netcord-gateway.md
T
Toutsu 05ca8061e9
PR Checks / test-and-build (pull_request) Successful in 5m46s
feat: add Discord NetCord gateway worker
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
2026-05-18 16:04:31 +03:00

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 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

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.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.