feat(discord): add /listsessions slash command and handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
<ItemGroup>
|
||||||
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
|
<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="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
||||||
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
|
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
|
||||||
<PackageReference Include="NetCord.Hosting.Services" 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" />
|
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||||
</ItemGroup>
|
</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user