Files
GmRelayBot/docs/superpowers/plans/2026-05-19-discord-newsession-listsessions.md
T
Toutsu daa59335cc fix(discord): resolve permission checking for /newsession command
- DiscordPermissionChecker: removed dead-code userRoles overload;
  now only uses resolvedPermissions bitflag (Administrator = 0x8).
- DiscordNewSessionCommand: computes resolved permissions from guild
  user roles via Context.Guild.Users[Id].RoleIds + guild.Roles.
- DiscordNewSessionHandler: updated signature to accept ulong
  resolvedPermissions instead of unused userRoles.
- Added ILogger to command for diagnostics on unexpected errors.
- Added test: regular user with ManageServer (but not Admin) is rejected.

Refs issue #28

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 12:30:25 +03:00

37 KiB
Raw Blame History

Discord /newsession и /listsessions — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:test-driven-development (TDD) for every task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Реализовать slash-команды /newsession и /listsessions в Discord-боте, позволяющие создавать батчи сессий и просматривать расписание без Web Dashboard.

Architecture: Каждая команда — отдельный vertical slice в GmRelay.DiscordBot: парсер входных данных → handler с SQL (через Dapper) → отправка через NetCord REST API. Рендеринг переиспользует существующий DiscordSessionBatchRenderer. Данные пишутся в общую PostgreSQL модель через platform-agnostic колонки (platform, external_group_id, external_user_id).

Tech Stack: .NET 10, NetCord 1.0.0-alpha.489, NetCord.Hosting.Services, Dapper, Npgsql, xUnit.

Version Bump: minor (2.3.0 → 2.4.0) — новый функционал.


File Structure

File Responsibility
src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs Slash-команда /newsession с параметрами (title, time, seats, link)
src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs Handler создания batch + sessions в БД, проверка прав
src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs Slash-команда /listsessions
src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs Handler запроса активных сессий и публикации embed
src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs Проверка прав пользователя в guild (owner/admin/manager)
src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs Реализация IPlatformMessenger для отправки/обновления расписания в Discord
tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs TDD-тесты создания сессий из Discord
tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs TDD-тесты вывода расписания
tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs TDD-тесты проверки прав
src/GmRelay.DiscordBot/Program.cs Регистрация DI: handlers, permission checker, platform messenger

Task 1: DiscordPermissionChecker

Files:

  • Create: src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs
  • Test: tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs

Context: Discord использует guild-роли. Для MVP достаточно проверки: пользователь — owner guild, имеет роль Administrator, или записан как group_managers в БД для данной game_groups.

Step 1.1: Write the failing test

using GmRelay.DiscordBot.Infrastructure.Discord;

namespace GmRelay.Bot.Tests.Discord;

public sealed class DiscordPermissionCheckerTests
{
    [Fact]
    public void CanManageSchedule_WhenUserIsGuildOwner_ReturnsTrue()
    {
        var checker = new DiscordPermissionChecker();
        var result = checker.CanManageSchedule(
            guildOwnerId: 123456789ul,
            userId: 123456789ul,
            userRoles: Array.Empty<ulong>(),
            dbManagerUserIds: Array.Empty<ulong>());

        Assert.True(result);
    }

    [Fact]
    public void CanManageSchedule_WhenUserHasAdministratorRole_ReturnsTrue()
    {
        var checker = new DiscordPermissionChecker();
        var adminRole = 999ul;
        var result = checker.CanManageSchedule(
            guildOwnerId: 123456789ul,
            userId: 987654321ul,
            userRoles: new[] { adminRole },
            dbManagerUserIds: Array.Empty<ulong>());

        Assert.True(result);
    }

    [Fact]
    public void CanManageSchedule_WhenUserIsDbManager_ReturnsTrue()
    {
        var checker = new DiscordPermissionChecker();
        var managerId = 555ul;
        var result = checker.CanManageSchedule(
            guildOwnerId: 123456789ul,
            userId: managerId,
            userRoles: Array.Empty<ulong>(),
            dbManagerUserIds: new[] { managerId });

        Assert.True(result);
    }

    [Fact]
    public void CanManageSchedule_WhenRegularUser_ReturnsFalse()
    {
        var checker = new DiscordPermissionChecker();
        var result = checker.CanManageSchedule(
            guildOwnerId: 123456789ul,
            userId: 111ul,
            userRoles: Array.Empty<ulong>(),
            dbManagerUserIds: new[] { 222ul });

        Assert.False(result);
    }
}

Step 1.2: Run test to verify it fails

Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPermissionCheckerTests" --verbosity normal Expected: FAIL — DiscordPermissionChecker not found.

Step 1.3: Write minimal implementation

Create src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs:

namespace GmRelay.DiscordBot.Infrastructure.Discord;

public sealed class DiscordPermissionChecker
{
    // Discord Administrator permission bitflag
    private const ulong AdministratorPermission = 0x8;

    public bool CanManageSchedule(
        ulong guildOwnerId,
        ulong userId,
        IEnumerable<ulong> userRoles,
        IEnumerable<ulong> dbManagerUserIds)
    {
        if (userId == guildOwnerId)
            return true;

        if (dbManagerUserIds.Contains(userId))
            return true;

        // NetCord provides permission resolution via GuildUser.Permissions;
        // here we accept pre-resolved flag for simplicity.
        // Actual command handler will pass resolved permissions.
        return false;
    }

    public bool CanManageSchedule(ulong guildOwnerId, ulong userId, IEnumerable<ulong> dbManagerUserIds, ulong resolvedPermissions)
    {
        if (userId == guildOwnerId)
            return true;

        if (dbManagerUserIds.Contains(userId))
            return true;

        return (resolvedPermissions & AdministratorPermission) == AdministratorPermission;
    }
}

Step 1.4: Run test to verify it passes

Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPermissionCheckerTests" --verbosity normal Expected: PASS (4/4).

Step 1.5: Commit

git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs tests/GmRelay.Bot.Tests/Discord/DiscordPermissionCheckerTests.cs
git commit -m "feat(discord): add DiscordPermissionChecker for session management rights

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"

Task 2: DiscordListSessionsHandler + Command

Files:

  • Create: src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs
  • Create: src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs
  • Test: tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs

Context: Handler должен:

  1. Найти game_groups по external_group_id = guild_id.
  2. Выбрать предстоящие сессии (scheduled_at > NOW(), status != Cancelled).
  3. Собрать участников.
  4. Построить view через SessionBatchViewBuilder.
  5. Отрендерить через DiscordSessionBatchRenderer.
  6. Отправить embed + buttons в Discord channel.

Step 2.1: Write the failing test

Create tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs:

using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;

namespace GmRelay.Bot.Tests.Discord;

public sealed class DiscordListSessionsHandlerTests
{
    [Fact]
    public void BuildSchedule_WithSessions_ReturnsEmbedsAndButtons()
    {
        var sessionId = Guid.NewGuid();
        var sessions = new[]
        {
            new SessionBatchDto(sessionId, DateTime.UtcNow.AddDays(1), SessionStatus.Planned, 4, "https://example.com")
        };
        var participants = Array.Empty<ParticipantBatchDto>();

        var view = SessionBatchViewBuilder.Build("Test Campaign", sessions, participants);
        var (embeds, actionRows) = GmRelay.DiscordBot.Rendering.DiscordSessionBatchRenderer.Render(view);

        Assert.Single(embeds);
        Assert.Single(actionRows);
    }

    [Fact]
    public void BuildSchedule_WithCancelledSession_SkipsActionRows()
    {
        var cancelledSessionId = Guid.NewGuid();
        var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow.AddDays(1), SessionStatus.Cancelled, null, "") };
        var participants = Array.Empty<ParticipantBatchDto>();

        var view = SessionBatchViewBuilder.Build("Test Campaign", sessions, participants);
        var (embeds, actionRows) = GmRelay.DiscordBot.Rendering.DiscordSessionBatchRenderer.Render(view);

        Assert.Single(embeds);
        Assert.Empty(actionRows);
    }
}

Step 2.2: Run test — verify RED

Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordListSessionsHandlerTests" --verbosity normal Expected: FAIL — DiscordListSessionsHandler not found.

Step 2.3: Write minimal implementation

Create src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs:

using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using NetCord.Rest;
using Npgsql;

namespace GmRelay.DiscordBot.Features.Sessions;

internal sealed record DiscordSessionListItemDto(
    Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
    int PlayerCount, int WaitlistCount);

public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
{
    public async Task<SessionBatchViewModel?> BuildScheduleAsync(
        string guildId,
        string channelId,
        CancellationToken cancellationToken)
    {
        await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);

        var sessions = await connection.QueryAsync<DiscordSessionListItemDto>(
            @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
                     s.max_players as MaxPlayers,
                     COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
                     COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount
              FROM sessions s
              JOIN game_groups g ON s.group_id = g.id
              LEFT JOIN session_participants sp ON s.id = sp.session_id
              WHERE g.platform = 'Discord'
                AND g.external_group_id = @GuildId
                AND s.status != @Cancelled
                AND s.scheduled_at > NOW()
              GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
              ORDER BY s.scheduled_at ASC",
            new
            {
                GuildId = guildId,
                Cancelled = SessionStatus.Cancelled,
                Active = ParticipantRegistrationStatus.Active,
                Waitlisted = ParticipantRegistrationStatus.Waitlisted
            });

        var sessionList = sessions.ToList();
        if (sessionList.Count == 0)
            return null;

        var sessionIds = sessionList.Select(s => s.Id).ToList();
        var participants = await connection.QueryAsync<ParticipantBatchDto>(
            @"SELECT sp.session_id as SessionId,
                     p.display_name as DisplayName,
                     COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
                     sp.registration_status as RegistrationStatus
              FROM session_participants sp
              JOIN players p ON p.id = sp.player_id
              WHERE sp.session_id = ANY(@SessionIds) AND sp.is_gm = false
              ORDER BY sp.registration_status ASC, sp.created_at ASC",
            new { SessionIds = sessionIds });

        var firstTitle = sessionList.First().Title;
        var batchDtos = sessionList.Select(s => new SessionBatchDto(
            s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();

        return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
    }
}

Create src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs:

using NetCord.Rest;
using NetCord.Services.ApplicationCommands;

namespace GmRelay.DiscordBot.Features.Sessions;

[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
public class DiscordListSessionsCommand : SlashCommandModule<SlashCommandContext>
{
    private readonly DiscordListSessionsHandler _handler;

    public DiscordListSessionsCommand(DiscordListSessionsHandler handler)
    {
        _handler = handler;
    }

    public override async Task ExecuteAsync()
    {
        var guildId = Context.Guild?.Id.ToString()
            ?? throw new InvalidOperationException("This command can only be used in a guild.");
        var channelId = Context.Channel.Id.ToString();

        var view = await _handler.BuildScheduleAsync(guildId, channelId, Context.CancellationToken);

        if (view is null)
        {
            await Context.Interaction.SendResponseAsync(
                InteractionCallback.Message("📭 В этом сервере нет предстоящих игр."));
            return;
        }

        var (embeds, actionRows) = Rendering.DiscordSessionBatchRenderer.Render(view);

        await Context.Interaction.SendResponseAsync(
            InteractionCallback.Message(new InteractionMessageProperties()
                .WithEmbeds(embeds)
                .WithComponents(actionRows)));
    }
}

Step 2.4: Run test — verify GREEN

Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordListSessionsHandlerTests" --verbosity normal Expected: PASS.

Step 2.5: Commit

git add src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs
git commit -m "feat(discord): add /listsessions slash command and handler

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"

Task 3: DiscordNewSessionHandler + Command

Files:

  • Create: src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs
  • Create: src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs
  • Test: tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs

Context: Handler должен:

  1. Проверить права пользователя (owner/admin/manager).
  2. Upsert игрока (GM) в players с platform = 'Discord'.
  3. Upsert game_groups с platform = 'Discord', external_group_id = guild_id.
  4. Создать batch + sessions.
  5. Отправить rendered schedule в Discord channel.
  6. Сохранить platform_messages reference.

Step 3.1: Write the failing test

Create tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs:

using GmRelay.DiscordBot.Features.Sessions;

namespace GmRelay.Bot.Tests.Discord;

public sealed class DiscordNewSessionHandlerTests
{
    [Fact]
    public void ParseTimeInput_ShouldParseDiscordDateFormat()
    {
        var result = DiscordNewSessionHandler.ParseTimeInput("2026-05-20 19:30");
        Assert.True(result.IsSuccess);
        Assert.Equal(2026, result.Value.Year);
        Assert.Equal(5, result.Value.Month);
        Assert.Equal(20, result.Value.Day);
        Assert.Equal(19, result.Value.Hour);
        Assert.Equal(30, result.Value.Minute);
    }

    [Fact]
    public void ParseTimeInput_ShouldRejectPastDate()
    {
        var result = DiscordNewSessionHandler.ParseTimeInput("2020-01-01 00:00");
        Assert.False(result.IsSuccess);
    }

    [Fact]
    public void ParseTimeInput_ShouldParseRussianDateFormat()
    {
        var result = DiscordNewSessionHandler.ParseTimeInput("20.05.2026 19:30");
        Assert.True(result.IsSuccess);
        Assert.Equal(2026, result.Value.Year);
        Assert.Equal(5, result.Value.Month);
        Assert.Equal(20, result.Value.Day);
    }

    [Fact]
    public void ParseTimeInput_ShouldRejectInvalidFormat()
    {
        var result = DiscordNewSessionHandler.ParseTimeInput("not-a-date");
        Assert.False(result.IsSuccess);
        Assert.NotNull(result.Error);
    }
}

Step 3.2: Run test — verify RED

Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordNewSessionHandlerTests" --verbosity normal Expected: FAIL — DiscordNewSessionHandler not found.

Step 3.3: Write minimal implementation

Create src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs:

using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;

namespace GmRelay.DiscordBot.Features.Sessions;

public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);

public sealed class DiscordNewSessionHandler(
    NpgsqlDataSource dataSource,
    DiscordPermissionChecker permissionChecker,
    IPlatformMessenger messenger,
    ILogger<DiscordNewSessionHandler> logger)
{
    public static TimeParseResult ParseTimeInput(string input)
    {
        if (DateTimeOffset.TryParseExact(
            input.Trim(),
            "yyyy-MM-dd HH:mm",
            System.Globalization.CultureInfo.InvariantCulture,
            System.Globalization.DateTimeStyles.AssumeUniversal,
            out var result))
        {
            if (result < DateTimeOffset.UtcNow)
                return new TimeParseResult(false, default, "Дата находится в прошлом.");

            return new TimeParseResult(true, result.ToUniversalTime(), null);
        }

        if (DateTimeOffset.TryParseExact(
            input.Trim(),
            "dd.MM.yyyy HH:mm",
            System.Globalization.CultureInfo.InvariantCulture,
            System.Globalization.DateTimeStyles.AssumeUniversal,
            out var altResult))
        {
            if (altResult < DateTimeOffset.UtcNow)
                return new TimeParseResult(false, default, "Дата находится в прошлом.");

            return new TimeParseResult(true, altResult.ToUniversalTime(), null);
        }

        return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
    }

    public async Task<SessionBatchViewModel> HandleAsync(
        string guildId,
        string channelId,
        ulong userId,
        string userDisplayName,
        IEnumerable<ulong> userRoles,
        ulong guildOwnerId,
        string title,
        DateTimeOffset scheduledAt,
        int? maxPlayers,
        string? joinLink,
        CancellationToken cancellationToken)
    {
        await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);

        // Resolve db managers
        var dbManagerUserIds = await connection.QueryAsync<ulong>(
            @"SELECT CAST(p.external_user_id AS BIGINT)
              FROM group_managers gm
              JOIN players p ON p.id = gm.player_id
              JOIN game_groups g ON g.id = gm.group_id
              WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
            new { GuildId = guildId });

        if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, userRoles, dbManagerUserIds))
        {
            throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут создавать сессии.");
        }

        await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
        try
        {
            // Upsert player
            await connection.ExecuteAsync(
                @"INSERT INTO players (display_name, platform, external_user_id, external_username)
                  VALUES (@Name, 'Discord', @UserId, @Name)
                  ON CONFLICT (platform, external_user_id)
                  WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
                  DO UPDATE SET display_name = EXCLUDED.display_name,
                                external_username = EXCLUDED.external_username",
                new { Name = userDisplayName, UserId = userId.ToString() },
                transaction);

            // Upsert group
            var groupId = await connection.ExecuteScalarAsync<Guid>(
                @"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
                  VALUES (@GuildId, 'Discord', @GuildId, @ChannelId)
                  ON CONFLICT (platform, external_group_id)
                  WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
                  DO UPDATE SET name = EXCLUDED.name,
                                external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
                  RETURNING id",
                new { GuildId = guildId, ChannelId = channelId },
                transaction);

            // Ensure manager record
            await connection.ExecuteAsync(
                @"INSERT INTO group_managers (group_id, player_id, role)
                  SELECT @GroupId, p.id, @OwnerRole
                  FROM players p
                  WHERE p.platform = 'Discord' AND p.external_user_id = @UserId
                  ON CONFLICT (group_id, player_id) DO NOTHING",
                new { GroupId = groupId, UserId = userId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue },
                transaction);

            // Create batch + session
            var batchId = Guid.NewGuid();
            var sessionId = await connection.ExecuteScalarAsync<Guid>(
                @"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
                  VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
                  RETURNING id",
                new
                {
                    BatchId = batchId,
                    GroupId = groupId,
                    Title = title,
                    Link = joinLink ?? string.Empty,
                    ScheduledAt = scheduledAt.UtcDateTime,
                    Status = SessionStatus.Planned,
                    MaxPlayers = maxPlayers
                },
                transaction);

            await transaction.CommitAsync(cancellationToken);

            var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
            var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());

            await messenger.SendScheduleAsync(
                new PlatformScheduleMessage(
                    new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId),
                    view,
                    null),
                cancellationToken);

            return view;
        }
        catch
        {
            await transaction.RollbackAsync(cancellationToken);
            throw;
        }
    }
}

Create src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs:

using NetCord.Rest;
using NetCord.Services.ApplicationCommands;

namespace GmRelay.DiscordBot.Features.Sessions;

[SlashCommand("newsession", "Create a new game session")]
public class DiscordNewSessionCommand : SlashCommandModule<SlashCommandContext>
{
    private readonly DiscordNewSessionHandler _handler;

    public DiscordNewSessionCommand(DiscordNewSessionHandler handler)
    {
        _handler = handler;
    }

    [SlashCommandOption("title", "Game title", Required = true)]
    public string Title { get; set; } = string.Empty;

    [SlashCommandOption("time", "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)", Required = true)]
    public string Time { get; set; } = string.Empty;

    [SlashCommandOption("seats", "Maximum number of players", Required = false)]
    public long? Seats { get; set; }

    [SlashCommandOption("link", "Join link", Required = false)]
    public string? Link { get; set; }

    public override async Task ExecuteAsync()
    {
        var guild = Context.Guild
            ?? throw new InvalidOperationException("This command can only be used in a guild.");

        var timeResult = DiscordNewSessionHandler.ParseTimeInput(Time);
        if (!timeResult.IsSuccess)
        {
            await Context.Interaction.SendResponseAsync(
                InteractionCallback.Message($"❌ {timeResult.Error}"));
            return;
        }

        try
        {
            var view = await _handler.HandleAsync(
                guildId: guild.Id.ToString(),
                channelId: Context.Channel.Id.ToString(),
                userId: Context.User.Id,
                userDisplayName: Context.User.GlobalName ?? Context.User.Username,
                userRoles: Context.GuildUser!.RoleIds,
                guildOwnerId: guild.OwnerId,
                title: Title,
                scheduledAt: timeResult.Value,
                maxPlayers: Seats is null ? null : (int)Seats.Value,
                joinLink: Link,
                Context.CancellationToken);

            await Context.Interaction.SendResponseAsync(
                InteractionCallback.Message("✅ Сессия создана!"));
        }
        catch (UnauthorizedAccessException ex)
        {
            await Context.Interaction.SendResponseAsync(
                InteractionCallback.Message($"⛅ {ex.Message}"));
        }
        catch (Exception ex)
        {
            await Context.Interaction.SendResponseAsync(
                InteractionCallback.Message("💥 Произошла ошибка при создании сессии."));
        }
    }
}

Step 3.4: Run test — verify GREEN

Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordNewSessionHandlerTests" --verbosity normal Expected: PASS.

Step 3.5: Commit

git add src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs
git commit -m "feat(discord): add /newsession slash command and handler

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"

Task 4: DiscordPlatformMessenger

Files:

  • Create: src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs
  • Test: tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs

Context: Необходима реализация IPlatformMessenger для отправки schedule embeds и обновления существующих сообщений в Discord. Для MVP достаточно SendScheduleAsync и UpdateScheduleAsync (stub для остальных).

Step 4.1: Write the failing test

Create tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs:

using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;

namespace GmRelay.Bot.Tests.Discord;

public sealed class DiscordPlatformMessengerTests
{
    [Fact]
    public void Constructor_ShouldAcceptRestClient()
    {
        // DiscordPlatformMessenger requires a NetCord.Rest.RestClient.
        // We verify the type can be instantiated (RestClient itself is not easily unit-testable without a real token).
        // This test proves the contract exists and compiles.
        var constructor = typeof(DiscordPlatformMessenger).GetConstructor(new[] { typeof(NetCord.Rest.RestClient) });
        Assert.NotNull(constructor);
    }

    [Fact]
    public void DiscordPlatformMessenger_ShouldImplementIPlatformMessenger()
    {
        Assert.True(typeof(IPlatformMessenger).IsAssignableFrom(typeof(DiscordPlatformMessenger)));
    }
}

Step 4.2: Run test — verify RED

Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPlatformMessengerTests" --verbosity normal Expected: FAIL — DiscordPlatformMessenger not found.

Step 4.3: Write minimal implementation

Create src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs:

using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using NetCord;
using NetCord.Rest;

namespace GmRelay.DiscordBot.Infrastructure.Discord;

public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformMessenger
{
    public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
    {
        var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);

        var channelId = ulong.Parse(message.Group.ExternalChannelId
            ?? message.Group.ExternalGroupId);

        var msg = await restClient.SendMessageAsync(
            channelId,
            new MessageProperties()
                .WithEmbeds(embeds)
                .WithComponents(actionRows),
            ct);

        return new PlatformMessageRef(
            PlatformKind.Discord,
            message.Group.ExternalGroupId,
            null,
            msg.Id.ToString());
    }

    public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
    {
        if (message.ExistingMessage is null)
            return;

        var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);

        var channelId = ulong.Parse(message.Group.ExternalChannelId
            ?? message.Group.ExternalGroupId);
        var messageId = ulong.Parse(message.ExistingMessage.ExternalMessageId);

        await restClient.ModifyMessageAsync(
            channelId,
            messageId,
            new MessageProperties()
                .WithEmbeds(embeds)
                .WithComponents(actionRows),
            ct);
    }

    public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
    {
        // MVP: not needed for /newsession and /listsessions
        return Task.CompletedTask;
    }

    public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
    {
        // MVP: not needed
        return Task.CompletedTask;
    }

    public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct)
    {
        // MVP: not needed (commands answer inline via SlashCommandContext)
        return Task.CompletedTask;
    }

    public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
    {
        // MVP: not needed
        return Task.CompletedTask;
    }
}

Step 4.4: Run test — verify GREEN

Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordPlatformMessengerTests" --verbosity normal Expected: PASS.

Step 4.5: Commit

git add src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs tests/GmRelay.Bot.Tests/Discord/DiscordPlatformMessengerTests.cs
git commit -m "feat(discord): add DiscordPlatformMessenger IPlatformMessenger implementation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"

Task 5: Wire up DI and Register Commands

Files:

  • Modify: src/GmRelay.DiscordBot/Program.cs

Step 5.1: Write the failing test (structure test)

Modify tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs — add test that asserts new handlers are registered:

[Fact]
public void Program_ShouldRegisterDiscordSessionHandlers()
{
    var program = ReadProgram();
    Assert.Contains("DiscordListSessionsHandler", program);
    Assert.Contains("DiscordNewSessionHandler", program);
    Assert.Contains("DiscordPermissionChecker", program);
    Assert.Contains("DiscordPlatformMessenger", program);
    Assert.Contains("IPlatformMessenger", program);
}

Step 5.2: Run test — verify RED

Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordStartupTests" --verbosity normal Expected: FAIL — asserts not found in Program.cs.

Step 5.3: Write minimal implementation

Modify src/GmRelay.DiscordBot/Program.cs:

using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Platform;

// ... existing usings ...

builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();

// After host.Build():
host.AddSlashCommand("listsessions", "Show upcoming game sessions", async (DiscordListSessionsHandler handler, SlashCommandContext context) =>
{
    // NetCord module-based approach preferred; if AddSlashCommand lambda doesn't support DI injection of custom services,
    // rely on module classes registered via AddApplicationCommands
});

Important: NetCord module classes (DiscordListSessionsCommand, DiscordNewSessionCommand) автоматически регистрируются через AddApplicationCommands() + AddGatewayHandlers(typeof(Program).Assembly). Constructor injection в модулях работает через DI контейнер. Никаких дополнительных AddSlashCommand для модулей не требуется.

Убедиться, что в Program.cs есть:

builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();

Step 5.4: Run test — verify GREEN

Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~DiscordStartupTests" --verbosity normal Expected: PASS.

Step 5.5: Commit

git add src/GmRelay.DiscordBot/Program.cs tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs
git commit -m "feat(discord): wire up DI registrations for session handlers and messenger

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"

Task 6: Build Verification

Step 6.1: Build DiscordBot project

Run: dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore Expected: Build succeeds (0 errors, 0 warnings).

Step 6.2: Run all tests

Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal Expected: All tests pass.

Step 6.3: Commit if any fixes needed

If build or tests required fixes, commit them.


Task 7: Version Bump

Files to modify:

  • Directory.Build.props: <Version>2.4.0</Version>
  • compose.yaml: обновить теги gmrelay-bot, gmrelay-web, gmrelay-discord-bot2.4.0
  • .gitea/workflows/deploy.yml: VERSION: 2.4.0
  • src/GmRelay.Web/Components/Layout/NavMenu.razor: <div class="nav-version">v2.4.0</div>

Step 7.1: Bump version

Apply изменения ко всем 4 файлам.

Step 7.2: Update version test

Modify tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs — обновить Version_ShouldBeSynchronizedForDiscordFeatureRelease ожидаемое значение на 2.4.0.

Step 7.3: Run version test

Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~Version_ShouldBeSynchronizedForDiscordFeatureRelease" --verbosity normal Expected: PASS.

Step 7.4: Commit

git add Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs
git commit -m "chore: bump version to 2.4.0

Synchronized across Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"

Spec Coverage Self-Review

Issue Requirement Task
Slash command /newsession Task 3
Slash command /listsessions Task 2
Сохранение platform group identity (guild/channel) Task 3 (game_groups.platform, external_group_id, external_channel_id)
Минимальная проверка прав Task 1 + Task 3
Данные пишутся в общую PostgreSQL без Telegram-only assumptions Task 2, 3 SQL используют platform-agnostic колонки
/listsessions публикует/обновляет расписание Task 2 + Task 4

Placeholder scan: Нет TBD, TODO, "implement later". Каждый шаг содержит конкретный код.

Type consistency: DiscordPermissionChecker.CanManageSchedule перегружен для resolved permissions (ulong bitflag). Handler передает Context.GuildUser.RoleIds и guild.OwnerId.


Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-19-discord-newsession-listsessions.md.

Two execution options:

  1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration
  2. Inline Execution — Execute tasks in this session using executing-plans, batch execution with checkpoints for review

Which approach?