feat(discord): enable session join leave buttons
PR Checks / test-and-build (pull_request) Successful in 6m6s
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:
@@ -0,0 +1,64 @@
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public sealed record DiscordSessionInteractionInput(
|
||||
Guid SessionId,
|
||||
string InteractionId,
|
||||
string GuildId,
|
||||
string ChannelId,
|
||||
string MessageId,
|
||||
ulong UserId,
|
||||
string Username,
|
||||
string? DisplayName);
|
||||
|
||||
public static class DiscordSessionInteractionMapper
|
||||
{
|
||||
public static bool TryParseCustomId(string customId, string expectedAction, out Guid sessionId)
|
||||
{
|
||||
sessionId = default;
|
||||
|
||||
var parts = customId.Split(':', 2);
|
||||
return parts.Length == 2
|
||||
&& string.Equals(parts[0], expectedAction, StringComparison.Ordinal)
|
||||
&& Guid.TryParse(parts[1], out sessionId);
|
||||
}
|
||||
|
||||
public static JoinSessionCommand CreateJoinCommand(DiscordSessionInteractionInput input) =>
|
||||
new(
|
||||
SessionId: input.SessionId,
|
||||
User: CreateUser(input),
|
||||
InteractionId: input.InteractionId,
|
||||
Group: CreateGroup(input),
|
||||
ScheduleMessage: CreateMessageRef(input));
|
||||
|
||||
public static LeaveSessionCommand CreateLeaveCommand(DiscordSessionInteractionInput input) =>
|
||||
new(
|
||||
SessionId: input.SessionId,
|
||||
User: CreateUser(input),
|
||||
InteractionId: input.InteractionId,
|
||||
Group: CreateGroup(input),
|
||||
ScheduleMessage: CreateMessageRef(input));
|
||||
|
||||
private static PlatformUser CreateUser(DiscordSessionInteractionInput input) =>
|
||||
new(
|
||||
PlatformKind.Discord,
|
||||
input.UserId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
string.IsNullOrWhiteSpace(input.DisplayName) ? input.Username : input.DisplayName,
|
||||
input.Username);
|
||||
|
||||
private static PlatformGroup CreateGroup(DiscordSessionInteractionInput input) =>
|
||||
new(
|
||||
PlatformKind.Discord,
|
||||
input.GuildId,
|
||||
input.GuildId,
|
||||
input.ChannelId);
|
||||
|
||||
private static PlatformMessageRef CreateMessageRef(DiscordSessionInteractionInput input) =>
|
||||
new(
|
||||
PlatformKind.Discord,
|
||||
input.GuildId,
|
||||
null,
|
||||
input.MessageId);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Concurrent;
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
|
||||
public sealed class DiscordInteractionReplyCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PlatformInteractionReply> replies = new(StringComparer.Ordinal);
|
||||
|
||||
public void Store(PlatformInteractionReply reply) =>
|
||||
replies[reply.InteractionId] = reply;
|
||||
|
||||
public PlatformInteractionReply? Take(string interactionId) =>
|
||||
replies.TryRemove(interactionId, out var reply)
|
||||
? reply
|
||||
: null;
|
||||
}
|
||||
@@ -6,7 +6,9 @@ using NetCord.Rest;
|
||||
|
||||
namespace GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
|
||||
public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformMessenger
|
||||
public sealed class DiscordPlatformMessenger(
|
||||
RestClient restClient,
|
||||
DiscordInteractionReplyCache interactionReplies) : IPlatformMessenger
|
||||
{
|
||||
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
|
||||
{
|
||||
@@ -61,6 +63,7 @@ public sealed class DiscordPlatformMessenger(RestClient restClient) : IPlatformM
|
||||
|
||||
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct)
|
||||
{
|
||||
interactionReplies.Store(reply);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using GmRelay.DiscordBot;
|
||||
using GmRelay.DiscordBot.Features.Sessions;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.DiscordBot.Infrastructure.Logging;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -43,6 +44,10 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||
builder.Services.AddSingleton<DiscordPermissionChecker>();
|
||||
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
||||
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordInteractionReplyCache>();
|
||||
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
|
||||
|
||||
builder.Services
|
||||
|
||||
@@ -666,7 +666,12 @@
|
||||
}
|
||||
},
|
||||
"gmrelay.shared": {
|
||||
"type": "Project"
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Dapper": "[2.1.72, )",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )",
|
||||
"Npgsql": "[10.0.2, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user