feat(discord): add /listsessions slash command and handler

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 11:09:45 +03:00
parent ac8f03ecc9
commit 76456cc28a
4 changed files with 174 additions and 0 deletions
@@ -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<SlashCommandContext>
{
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)));
}
}
@@ -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<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());
}
}
@@ -10,9 +10,11 @@
<ItemGroup>
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
<PackageReference Include="NetCord.Hosting.Services" Version="1.0.0-alpha.489" />
<PackageReference Include="NetCord.Services" Version="1.0.0-alpha.489" />
<PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup>
@@ -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);
}
}