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:
@@ -1,5 +1,6 @@
|
||||
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
@@ -10,6 +10,7 @@ using GmRelay.Bot.Infrastructure.Health;
|
||||
using GmRelay.Bot.Infrastructure.Logging;
|
||||
using GmRelay.Bot.Infrastructure.Scheduling;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
@@ -63,6 +64,7 @@ builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<
|
||||
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
||||
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
|
||||
builder.Services.AddSingleton<CreateSessionHandler>();
|
||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
|
||||
|
||||
@@ -661,7 +661,12 @@
|
||||
}
|
||||
},
|
||||
"gmrelay.shared": {
|
||||
"type": "Project"
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Dapper": "[2.1.72, )",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )",
|
||||
"Npgsql": "[10.0.2, )"
|
||||
}
|
||||
}
|
||||
},
|
||||
"net10.0/win-x64": {
|
||||
|
||||
@@ -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, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-3
@@ -4,8 +4,9 @@ using Npgsql;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed record JoinSessionCommand(
|
||||
Guid SessionId,
|
||||
@@ -15,15 +16,17 @@ public sealed record JoinSessionCommand(
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
|
||||
// DTOs for AOT compilation
|
||||
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxPlayers);
|
||||
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
|
||||
|
||||
public sealed class JoinSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
IPlatformMessenger messenger,
|
||||
IScheduleMessageUpdateLock scheduleUpdateLock,
|
||||
ILogger<JoinSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
var transactionCommitted = false;
|
||||
@@ -64,7 +67,7 @@ public sealed class JoinSessionHandler(
|
||||
|
||||
// 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав.
|
||||
var batchInfo = await connection.QuerySingleOrDefaultAsync<JoinSessionBatchDto>(
|
||||
@"SELECT batch_id as BatchId, title as Title, max_players as MaxPlayers
|
||||
@"SELECT batch_id as BatchId, title as Title, status as Status, max_players as MaxPlayers
|
||||
FROM sessions
|
||||
WHERE id = @SessionId
|
||||
FOR UPDATE",
|
||||
@@ -78,6 +81,13 @@ public sealed class JoinSessionHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (SessionStatus.IsCancelled(batchInfo.Status))
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
|
||||
"""
|
||||
SELECT sp.registration_status
|
||||
+4
-1
@@ -2,9 +2,10 @@ using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed record LeaveSessionCommand(
|
||||
Guid SessionId,
|
||||
@@ -20,10 +21,12 @@ internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string Di
|
||||
public sealed class LeaveSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
IPlatformMessenger messenger,
|
||||
IScheduleMessageUpdateLock scheduleUpdateLock,
|
||||
ILogger<LeaveSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
var transactionCommitted = false;
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Collections.Concurrent;
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
|
||||
public interface IScheduleMessageUpdateLock
|
||||
{
|
||||
ValueTask<IAsyncDisposable> AcquireAsync(PlatformMessageRef scheduleMessage, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class ScheduleMessageUpdateLock : IScheduleMessageUpdateLock
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> locks = new(StringComparer.Ordinal);
|
||||
|
||||
public async ValueTask<IAsyncDisposable> AcquireAsync(PlatformMessageRef scheduleMessage, CancellationToken ct)
|
||||
{
|
||||
var key = CreateKey(scheduleMessage);
|
||||
var semaphore = locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
|
||||
await semaphore.WaitAsync(ct);
|
||||
return new Releaser(semaphore);
|
||||
}
|
||||
|
||||
private static string CreateKey(PlatformMessageRef scheduleMessage) =>
|
||||
string.Join(
|
||||
'\u001F',
|
||||
scheduleMessage.Platform.ToString(),
|
||||
scheduleMessage.ExternalGroupId,
|
||||
scheduleMessage.ExternalThreadId ?? string.Empty,
|
||||
scheduleMessage.ExternalMessageId);
|
||||
|
||||
private sealed class Releaser(SemaphoreSlim semaphore) : IAsyncDisposable
|
||||
{
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
semaphore.Release();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,10 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.72" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -2,11 +2,40 @@
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Dapper": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.1.72, )",
|
||||
"resolved": "2.1.72",
|
||||
"contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.5, )",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Npgsql": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.2, )",
|
||||
"resolved": "10.0.2",
|
||||
"contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"SecurityCodeScan.VS2019": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.7, )",
|
||||
"resolved": "5.6.7",
|
||||
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v2.4.0</div>
|
||||
<div class="nav-version">v2.5.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -243,7 +243,11 @@
|
||||
}
|
||||
},
|
||||
"gmrelay.shared": {
|
||||
"type": "Project"
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Dapper": "[2.1.72, )",
|
||||
"Npgsql": "[10.0.2, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user