diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs new file mode 100644 index 0000000..9dd107a --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs @@ -0,0 +1,38 @@ +using NetCord.Rest; +using NetCord.Services.ApplicationCommands; + +namespace GmRelay.DiscordBot.Features.Sessions; + +[SlashCommand("listsessions", "Show upcoming game sessions in this server")] +public class DiscordListSessionsCommand : ApplicationCommandModule +{ + private readonly DiscordListSessionsHandler _handler; + + public DiscordListSessionsCommand(DiscordListSessionsHandler handler) + { + _handler = handler; + } + + public 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, CancellationToken.None); + + 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))); + } +} diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs new file mode 100644 index 0000000..6d5c1ee --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs @@ -0,0 +1,65 @@ +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Rendering; +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 BuildScheduleAsync( + string guildId, + string channelId, + CancellationToken cancellationToken) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sessions = await connection.QueryAsync( + @"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( + @"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()); + } +} diff --git a/src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj b/src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj index 59723f3..49c7f1b 100644 --- a/src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj +++ b/src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj @@ -10,9 +10,11 @@ + + diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs new file mode 100644 index 0000000..b1a2398 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordListSessionsHandlerTests.cs @@ -0,0 +1,69 @@ +using System.IO; + +namespace GmRelay.Bot.Tests.Discord; + +public sealed class DiscordListSessionsHandlerTests +{ + 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 Handler_ShouldExist() + { + var repoRoot = GetRepoRoot(); + var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs"); + + Assert.True(File.Exists(handlerPath), "DiscordListSessionsHandler should exist."); + } + + [Fact] + public void Handler_ShouldQueryByPlatformAndExternalGroupId() + { + var repoRoot = GetRepoRoot(); + var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs"); + var handler = File.ReadAllText(handlerPath); + + Assert.Contains("platform = 'Discord'", handler, StringComparison.Ordinal); + Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal); + Assert.Contains("scheduled_at > NOW()", handler, StringComparison.Ordinal); + } + + [Fact] + public void Handler_ShouldNotContainTelegramSpecificColumns() + { + var repoRoot = GetRepoRoot(); + var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs"); + var handler = File.ReadAllText(handlerPath); + + Assert.DoesNotContain("telegram_chat_id", handler, StringComparison.Ordinal); + Assert.DoesNotContain("telegram_id", handler, StringComparison.Ordinal); + } + + [Fact] + public void Command_ShouldExist() + { + var repoRoot = GetRepoRoot(); + var commandPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsCommand.cs"); + + Assert.True(File.Exists(commandPath), "DiscordListSessionsCommand should exist."); + } + + [Fact] + public void Command_ShouldBeSlashCommandModule() + { + var repoRoot = GetRepoRoot(); + var commandPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsCommand.cs"); + var command = File.ReadAllText(commandPath); + + Assert.Contains("SlashCommand", command, StringComparison.Ordinal); + Assert.Contains("listsessions", command, StringComparison.Ordinal); + } +}