feat(discord): enable session join leave buttons
PR Checks / test-and-build (pull_request) Successful in 6m6s

Move neutral join/leave handlers into GmRelay.Shared so Telegram and Discord share capacity, waitlist, duplicate-click, and schedule-update behavior.

Add Discord component routing for join_session and leave_session buttons with deferred ephemeral replies and serialized schedule message updates.

Bump version to 2.5.0 and update Discord docs.

Refs #29
This commit is contained in:
2026-05-19 14:13:48 +03:00
parent 90da33154c
commit 39132be4e8
32 changed files with 644 additions and 78 deletions
@@ -0,0 +1,101 @@
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Features.Sessions.CreateSession;
using NetCord;
using NetCord.Rest;
using NetCord.Services.ComponentInteractions;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed class DiscordSessionInteractionModule(
JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
DiscordInteractionReplyCache interactionReplies,
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
{
[ComponentInteraction("join_session")]
public async Task JoinAsync(string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
try
{
await joinSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateJoinCommand(input),
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
}
[ComponentInteraction("leave_session")]
public async Task LeaveAsync(string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
try
{
await leaveSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateLeaveCommand(input),
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
}
private DiscordSessionInteractionInput CreateInput(Guid sessionId)
{
var guild = Context.Guild
?? throw new InvalidOperationException("Session buttons can only be used in a guild.");
var message = Context.Interaction.Message
?? throw new InvalidOperationException("Session button interaction must include a message.");
return new DiscordSessionInteractionInput(
SessionId: sessionId,
InteractionId: Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
GuildId: guild.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
ChannelId: Context.Channel.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
MessageId: message.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
UserId: Context.User.Id,
Username: Context.User.Username,
DisplayName: Context.User.GlobalName);
}
private async Task CompleteWithStoredReplyAsync(string interactionId)
{
var reply = interactionReplies.Take(interactionId);
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
}
private Task CompleteResponseAsync(string text) =>
ModifyResponseAsync(options => options.Content = text);
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
InteractionCallback.Message(
new InteractionMessageProperties()
.WithContent(text)
.WithFlags(MessageFlags.Ephemeral));
}