fix(discord): update sessions via interactions
Deploy Telegram Bot / build-and-push (push) Successful in 6m14s
Deploy Telegram Bot / scan-images (push) Successful in 3m12s
Deploy Telegram Bot / deploy (push) Successful in 31s

This commit is contained in:
2026-05-26 14:24:06 +03:00
parent 56aeca5288
commit 3447acd8c4
12 changed files with 445 additions and 68 deletions
@@ -0,0 +1,84 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record DiscordDeleteSessionResult(
string ReplyText,
SessionBatchViewModel? UpdatedView,
string? EmptyMessage = null);
public sealed class DiscordDeleteSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
DiscordListSessionsHandler listSessionsHandler,
ILogger<DiscordDeleteSessionHandler> logger)
{
public async Task<DiscordDeleteSessionResult> HandleAsync(
string guildId,
string channelId,
ulong userId,
ulong resolvedPermissions,
ulong guildOwnerId,
Guid sessionId,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
{
return new DiscordDeleteSessionResult(
"Только owner, администратор или manager могут удалять сессии.",
UpdatedView: null);
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var deletedRows = await connection.ExecuteAsync(
"""
DELETE FROM sessions s
USING game_groups g
WHERE s.group_id = g.id
AND s.id = @SessionId
AND g.platform = 'Discord'
AND g.external_group_id = @GuildId
""",
new { SessionId = sessionId, GuildId = guildId },
transaction);
await transaction.CommitAsync(cancellationToken);
if (deletedRows == 0)
{
return new DiscordDeleteSessionResult(
"Сессия не найдена или уже удалена.",
UpdatedView: null);
}
logger.LogInformation("Deleted Discord session {SessionId} in guild {GuildId}", sessionId, guildId);
var updatedView = await listSessionsHandler.BuildScheduleAsync(
guildId,
channelId,
userId,
resolvedPermissions,
guildOwnerId,
cancellationToken);
return updatedView is null
? new DiscordDeleteSessionResult(
"Сессия удалена.",
UpdatedView: null,
EmptyMessage: "В этом сервере нет предстоящих игр.")
: new DiscordDeleteSessionResult("Сессия удалена.", updatedView);
}
}
@@ -1,3 +1,4 @@
using NetCord;
using NetCord.Rest; using NetCord.Rest;
using NetCord.Services.ApplicationCommands; using NetCord.Services.ApplicationCommands;
@@ -18,8 +19,17 @@ public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandC
var guildId = Context.Interaction.GuildId?.ToString() var guildId = Context.Interaction.GuildId?.ToString()
?? throw new InvalidOperationException("This command can only be used in a guild."); ?? throw new InvalidOperationException("This command can only be used in a guild.");
var channelId = Context.Channel.Id.ToString(); var channelId = Context.Channel.Id.ToString();
var member = Context.User as GuildInteractionUser;
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
var guildOwnerId = 0UL;
var view = await _handler.BuildScheduleAsync(guildId, channelId, CancellationToken.None); var view = await _handler.BuildScheduleAsync(
guildId,
channelId,
Context.User.Id,
resolvedPermissions,
guildOwnerId,
CancellationToken.None);
if (view is null) if (view is null)
{ {
@@ -1,4 +1,5 @@
using Dapper; using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
@@ -9,11 +10,22 @@ internal sealed record DiscordSessionListItemDto(
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
int PlayerCount, int WaitlistCount); int PlayerCount, int WaitlistCount);
public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource) public sealed class DiscordListSessionsHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker)
{ {
public Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
CancellationToken cancellationToken) =>
BuildScheduleAsync(guildId, channelId, 0, 0, 0, cancellationToken);
public async Task<SessionBatchViewModel?> BuildScheduleAsync( public async Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId, string guildId,
string channelId, string channelId,
ulong userId,
ulong resolvedPermissions,
ulong guildOwnerId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
@@ -44,6 +56,20 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
if (sessionList.Count == 0) if (sessionList.Count == 0)
return null; return null;
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
new { GuildId = guildId });
var canManage = permissionChecker.CanManageSchedule(
guildOwnerId,
userId,
dbManagerUserIds,
resolvedPermissions);
var sessionIds = sessionList.Select(s => s.Id).ToList(); var sessionIds = sessionList.Select(s => s.Id).ToList();
var participants = await connection.QueryAsync<ParticipantBatchDto>( var participants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId, @"SELECT sp.session_id as SessionId,
@@ -60,6 +86,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
var batchDtos = sessionList.Select(s => new SessionBatchDto( var batchDtos = sessionList.Select(s => new SessionBatchDto(
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList(); s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList()); var view = SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
return canManage ? AddManagerActions(view) : view;
} }
internal static SessionBatchViewModel AddManagerActions(SessionBatchViewModel view) =>
view with
{
Sessions = view.Sessions
.Select(session =>
{
if (SessionStatus.IsCancelled(session.Status))
return session;
var actions = session.AvailableActions
.Concat([new AvailableAction("delete_session", $"Удалить {session.ScheduledAt.FormatMoscowShort()}", session.SessionId)])
.ToList();
return session with { AvailableActions = actions };
})
.ToList()
};
} }
@@ -1,8 +1,10 @@
using GmRelay.DiscordBot.Infrastructure.Discord; using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Confirmation.HandleRsvp; using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Sessions.CreateSession; using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform; using GmRelay.Shared.Platform;
using System.Collections;
using System.Globalization; using System.Globalization;
using NetCord; using NetCord;
using NetCord.Rest; using NetCord.Rest;
@@ -14,6 +16,7 @@ public sealed class DiscordSessionInteractionModule(
JoinSessionHandler joinSessionHandler, JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler, LeaveSessionHandler leaveSessionHandler,
HandleRsvpHandler rsvpHandler, HandleRsvpHandler rsvpHandler,
DiscordDeleteSessionHandler deleteSessionHandler,
DiscordRescheduleVoteHandler voteHandler, DiscordRescheduleVoteHandler voteHandler,
DiscordInteractionReplyCache interactionReplies, DiscordInteractionReplyCache interactionReplies,
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext> ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
@@ -28,21 +31,22 @@ public sealed class DiscordSessionInteractionModule(
} }
var input = CreateInput(parsedSessionId); var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)); await RespondAsync(InteractionCallback.DeferredModifyMessage);
SessionInteractionResult result;
try try
{ {
await joinSessionHandler.HandleAsync( result = await joinSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateJoinCommand(input), DiscordSessionInteractionMapper.CreateJoinCommand(input) with { DeferScheduleUpdate = true },
CancellationToken.None); CancellationToken.None);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId); logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку."); await FollowupEphemeralAsync("Не удалось обработать кнопку.");
return; return;
} }
await CompleteWithStoredReplyAsync(input.InteractionId); await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
} }
[ComponentInteraction("leave_session")] [ComponentInteraction("leave_session")]
@@ -55,21 +59,56 @@ public sealed class DiscordSessionInteractionModule(
} }
var input = CreateInput(parsedSessionId); var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)); await RespondAsync(InteractionCallback.DeferredModifyMessage);
SessionInteractionResult result;
try try
{ {
await leaveSessionHandler.HandleAsync( result = await leaveSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateLeaveCommand(input), DiscordSessionInteractionMapper.CreateLeaveCommand(input) with { DeferScheduleUpdate = true },
CancellationToken.None); CancellationToken.None);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId); logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку."); await FollowupEphemeralAsync("Не удалось обработать кнопку.");
return; return;
} }
await CompleteWithStoredReplyAsync(input.InteractionId); await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
}
[ComponentInteraction("delete_session")]
public async Task DeleteAsync(string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
var member = Context.User as GuildInteractionUser;
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
await RespondAsync(InteractionCallback.DeferredModifyMessage);
try
{
var result = await deleteSessionHandler.HandleAsync(
guildId: input.GuildId,
channelId: input.ChannelId,
userId: input.UserId,
resolvedPermissions: resolvedPermissions,
guildOwnerId: 0,
sessionId: parsedSessionId,
CancellationToken.None);
await CompleteDeleteResponseAsync(result);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord delete interaction for session {SessionId}", parsedSessionId);
await FollowupEphemeralAsync("Не удалось удалить сессию.");
}
} }
[ComponentInteraction("rsvp")] [ComponentInteraction("rsvp")]
@@ -124,7 +163,7 @@ public sealed class DiscordSessionInteractionModule(
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId); logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("РќРµ удалось обработать РєРЅРѕРїРєСѓ."); await CompleteResponseAsync("Не удалось обработать кнопку.");
return; return;
} }
@@ -190,9 +229,85 @@ public sealed class DiscordSessionInteractionModule(
await CompleteResponseAsync(reply?.Text ?? "Session updated."); await CompleteResponseAsync(reply?.Text ?? "Session updated.");
} }
private async Task CompleteScheduleUpdateResponseAsync(string interactionId, SessionInteractionResult result)
{
var updatedView = result.UpdatedView;
if (updatedView is not null && SourceMessageHasDeleteAction())
{
updatedView = DiscordListSessionsHandler.AddManagerActions(updatedView);
}
if (updatedView is not null)
{
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(updatedView);
await ModifyResponseAsync(options =>
{
options.Embeds = embeds;
options.Components = actionRows;
});
}
var reply = interactionReplies.Take(interactionId);
await FollowupEphemeralAsync(reply?.Text ?? result.ReplyText);
}
private async Task CompleteDeleteResponseAsync(DiscordDeleteSessionResult result)
{
if (result.UpdatedView is not null)
{
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(result.UpdatedView);
await ModifyResponseAsync(options =>
{
options.Embeds = embeds;
options.Components = actionRows;
});
}
else if (result.EmptyMessage is not null)
{
await ModifyResponseAsync(options =>
{
options.Content = result.EmptyMessage;
options.Embeds = [];
options.Components = [];
});
}
await FollowupEphemeralAsync(result.ReplyText);
}
private Task CompleteResponseAsync(string text) => private Task CompleteResponseAsync(string text) =>
ModifyResponseAsync(options => options.Content = text); ModifyResponseAsync(options => options.Content = text);
private Task FollowupEphemeralAsync(string text) =>
FollowupAsync(new InteractionMessageProperties()
.WithContent(text)
.WithFlags(MessageFlags.Ephemeral));
private bool SourceMessageHasDeleteAction() =>
Context.Interaction.Message?.Components.Any(ComponentContainsDeleteAction) == true;
private static bool ComponentContainsDeleteAction(object? component)
{
if (component is null)
return false;
if (component is IInteractiveComponent interactive
&& interactive.CustomId.StartsWith("delete_session:", StringComparison.Ordinal))
return true;
var nestedComponents = component.GetType().GetProperty("Components")?.GetValue(component) as IEnumerable;
if (nestedComponents is null)
return false;
foreach (var nestedComponent in nestedComponents)
{
if (ComponentContainsDeleteAction(nestedComponent))
return true;
}
return false;
}
private static InteractionCallbackProperties CreateEphemeralReply(string text) => private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
InteractionCallback.Message( InteractionCallback.Message(
new InteractionMessageProperties() new InteractionMessageProperties()
+1
View File
@@ -56,6 +56,7 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
builder.Services.AddSingleton<DiscordPermissionChecker>(); builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>(); builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>(); builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<DiscordRescheduleHandler>(); builder.Services.AddSingleton<DiscordRescheduleHandler>();
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>(); builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
@@ -13,7 +13,12 @@ public sealed record JoinSessionCommand(
PlatformUser User, PlatformUser User,
string InteractionId, string InteractionId,
PlatformGroup Group, PlatformGroup Group,
PlatformMessageRef ScheduleMessage); PlatformMessageRef ScheduleMessage,
bool DeferScheduleUpdate = false);
public sealed record SessionInteractionResult(
string ReplyText,
SessionBatchViewModel? UpdatedView = null);
// DTOs for AOT compilation // DTOs for AOT compilation
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers); internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
@@ -24,7 +29,7 @@ public sealed class JoinSessionHandler(
IScheduleMessageUpdateLock scheduleUpdateLock, IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<JoinSessionHandler> logger) ILogger<JoinSessionHandler> logger)
{ {
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct) public async Task<SessionInteractionResult> HandleAsync(JoinSessionCommand command, CancellationToken ct)
{ {
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct); await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
@@ -77,15 +82,13 @@ public sealed class JoinSessionHandler(
if (batchInfo is null) if (batchInfo is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct); return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
return;
} }
if (SessionStatus.IsCancelled(batchInfo.Status)) if (SessionStatus.IsCancelled(batchInfo.Status))
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct); return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
return;
} }
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>( var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
@@ -105,8 +108,7 @@ public sealed class JoinSessionHandler(
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы уже в листе ожидания!" ? "Вы уже в листе ожидания!"
: "Вы уже записаны!"; : "Вы уже записаны!";
await AnswerAsync(command.InteractionId, alreadyText, ct); return await AnswerAsync(command.InteractionId, alreadyText, ct);
return;
} }
var activeParticipants = await connection.ExecuteScalarAsync<int>( var activeParticipants = await connection.ExecuteScalarAsync<int>(
@@ -139,8 +141,7 @@ public sealed class JoinSessionHandler(
if (inserted == 0) if (inserted == 0)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct); return await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
return;
} }
// Загружаем весь батч для перерисовки // Загружаем весь батч для перерисовки
@@ -168,17 +169,20 @@ public sealed class JoinSessionHandler(
// 4. Перерисовываем сообщение // 4. Перерисовываем сообщение
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList()); var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
await messenger.UpdateScheduleAsync( if (!command.DeferScheduleUpdate)
new PlatformScheduleMessage( {
command.Group, await messenger.UpdateScheduleAsync(
view, new PlatformScheduleMessage(
command.ScheduleMessage), command.Group,
ct); view,
command.ScheduleMessage),
ct);
}
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Основной состав заполнен. Вы добавлены в лист ожидания." ? "Основной состав заполнен. Вы добавлены в лист ожидания."
: "Вы успешно записаны!"; : "Вы успешно записаны!";
await AnswerAsync(command.InteractionId, callbackText, ct); return await AnswerAsync(command.InteractionId, callbackText, ct, view);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -191,10 +195,17 @@ public sealed class JoinSessionHandler(
var errorText = transactionCommitted var errorText = transactionCommitted
? "Регистрация сохранена, но не удалось обновить сообщение расписания." ? "Регистрация сохранена, но не удалось обновить сообщение расписания."
: "Произошла ошибка при регистрации."; : "Произошла ошибка при регистрации.";
await AnswerAsync(command.InteractionId, errorText, ct); return await AnswerAsync(command.InteractionId, errorText, ct);
} }
} }
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) => private async Task<SessionInteractionResult> AnswerAsync(
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct); string interactionId,
string text,
CancellationToken ct,
SessionBatchViewModel? updatedView = null)
{
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
return new SessionInteractionResult(text, updatedView);
}
} }
@@ -12,7 +12,8 @@ public sealed record LeaveSessionCommand(
PlatformUser User, PlatformUser User,
string InteractionId, string InteractionId,
PlatformGroup Group, PlatformGroup Group,
PlatformMessageRef ScheduleMessage); PlatformMessageRef ScheduleMessage,
bool DeferScheduleUpdate = false);
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers); internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus); internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
@@ -24,7 +25,7 @@ public sealed class LeaveSessionHandler(
IScheduleMessageUpdateLock scheduleUpdateLock, IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<LeaveSessionHandler> logger) ILogger<LeaveSessionHandler> logger)
{ {
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct) public async Task<SessionInteractionResult> HandleAsync(LeaveSessionCommand command, CancellationToken ct)
{ {
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct); await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
@@ -49,15 +50,13 @@ public sealed class LeaveSessionHandler(
if (session is null) if (session is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct); return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
return;
} }
if (SessionStatus.IsCancelled(session.Status)) if (SessionStatus.IsCancelled(session.Status))
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct); return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
return;
} }
var platform = command.User.Platform.ToString(); var platform = command.User.Platform.ToString();
@@ -81,8 +80,7 @@ public sealed class LeaveSessionHandler(
if (participant is null) if (participant is null)
{ {
await transaction.RollbackAsync(ct); await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct); return await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
return;
} }
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -190,12 +188,15 @@ public sealed class LeaveSessionHandler(
transactionCommitted = true; transactionCommitted = true;
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants); var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync( if (!command.DeferScheduleUpdate)
new PlatformScheduleMessage( {
command.Group, await messenger.UpdateScheduleAsync(
view, new PlatformScheduleMessage(
command.ScheduleMessage), command.Group,
ct); view,
command.ScheduleMessage),
ct);
}
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы удалены из листа ожидания." ? "Вы удалены из листа ожидания."
@@ -203,7 +204,7 @@ public sealed class LeaveSessionHandler(
? "Вы отписались от сессии." ? "Вы отписались от сессии."
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}."; : $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
await AnswerAsync(command.InteractionId, callbackText, ct); return await AnswerAsync(command.InteractionId, callbackText, ct, view);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -216,10 +217,17 @@ public sealed class LeaveSessionHandler(
var errorText = transactionCommitted var errorText = transactionCommitted
? "Запись снята, но не удалось обновить сообщение расписания." ? "Запись снята, но не удалось обновить сообщение расписания."
: "Произошла ошибка при отмене записи."; : "Произошла ошибка при отмене записи.";
await AnswerAsync(command.InteractionId, errorText, ct); return await AnswerAsync(command.InteractionId, errorText, ct);
} }
} }
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) => private async Task<SessionInteractionResult> AnswerAsync(
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct); string interactionId,
string text,
CancellationToken ct,
SessionBatchViewModel? updatedView = null)
{
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
return new SessionInteractionResult(text, updatedView);
}
} }
+46 -15
View File
@@ -104,24 +104,38 @@ public sealed class SessionService(
public async Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId) public async Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
if (effectiveId is null) if (playerIds.Length == 0)
return []; return [];
return (await conn.QueryAsync<WebGameGroup>( return (await conn.QueryAsync<WebGameGroup>(
""" """
WITH visible_groups AS (
SELECT gm.group_id,
CASE
WHEN bool_or(gm.role = @OwnerRole) THEN @OwnerRole
ELSE @CoGmRole
END AS ManagerRole
FROM group_managers gm
WHERE gm.player_id = ANY(@PlayerIds)
GROUP BY gm.group_id
)
SELECT g.id, SELECT g.id,
g.telegram_chat_id AS TelegramChatId, g.telegram_chat_id AS TelegramChatId,
g.external_group_id AS ExternalGroupId, g.external_group_id AS ExternalGroupId,
g.name, g.name,
g.platform AS Platform, g.platform AS Platform,
gm.role AS ManagerRole vg.ManagerRole
FROM group_managers gm FROM visible_groups vg
JOIN game_groups g ON g.id = gm.group_id JOIN game_groups g ON g.id = vg.group_id
WHERE gm.player_id = @PlayerId
ORDER BY g.name ORDER BY g.name
""", """,
new { PlayerId = effectiveId.Value })).ToList(); new
{
PlayerIds = playerIds,
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
})).ToList();
} }
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId) public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
@@ -144,8 +158,8 @@ public sealed class SessionService(
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
if (effectiveId is null) if (playerIds.Length == 0)
return false; return false;
return await conn.ExecuteScalarAsync<bool>( return await conn.ExecuteScalarAsync<bool>(
@@ -154,17 +168,17 @@ public sealed class SessionService(
SELECT 1 SELECT 1
FROM group_managers FROM group_managers
WHERE group_id = @GroupId WHERE group_id = @GroupId
AND player_id = @PlayerId AND player_id = ANY(@PlayerIds)
) )
""", """,
new { GroupId = groupId, PlayerId = effectiveId.Value }); new { GroupId = groupId, PlayerIds = playerIds });
} }
public async Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) public async Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
if (effectiveId is null) if (playerIds.Length == 0)
return false; return false;
return await conn.ExecuteScalarAsync<bool>( return await conn.ExecuteScalarAsync<bool>(
@@ -173,11 +187,11 @@ public sealed class SessionService(
SELECT 1 SELECT 1
FROM group_managers FROM group_managers
WHERE group_id = @GroupId WHERE group_id = @GroupId
AND player_id = @PlayerId AND player_id = ANY(@PlayerIds)
AND role = @OwnerRole AND role = @OwnerRole
) )
""", """,
new { GroupId = groupId, PlayerId = effectiveId.Value, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); new { GroupId = groupId, PlayerIds = playerIds, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
} }
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId) public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
@@ -1561,6 +1575,23 @@ public sealed class SessionService(
return primaryId ?? playerId; return primaryId ?? playerId;
} }
private static async Task<Guid[]> _ResolveLinkedPlayerIdsAsync(NpgsqlConnection conn, string platform, string externalUserId)
{
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
if (effectiveId is null)
return [];
return (await conn.QueryAsync<Guid>(
"""
SELECT @EffectiveId
UNION
SELECT secondary_player_id
FROM player_links
WHERE primary_player_id = @EffectiveId
""",
new { EffectiveId = effectiveId.Value })).ToArray();
}
private static async Task<Guid> _UpsertPlayerAndGetIdAsync( private static async Task<Guid> _UpsertPlayerAndGetIdAsync(
NpgsqlConnection conn, string platform, string externalUserId, NpgsqlConnection conn, string platform, string externalUserId,
string displayName, string? avatarUrl, NpgsqlTransaction? transaction) string displayName, string? avatarUrl, NpgsqlTransaction? transaction)
@@ -47,6 +47,17 @@ public sealed class DiscordListSessionsHandlerTests
Assert.DoesNotContain("telegram_id", handler, StringComparison.Ordinal); Assert.DoesNotContain("telegram_id", handler, StringComparison.Ordinal);
} }
[Fact]
public void Handler_ShouldExposeDeleteActionForManagers()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordListSessionsHandler.cs");
var handler = File.ReadAllText(handlerPath);
Assert.Contains("delete_session", handler, StringComparison.Ordinal);
Assert.Contains("CanManageSchedule", handler, StringComparison.Ordinal);
}
[Fact] [Fact]
public void Command_ShouldExist() public void Command_ShouldExist()
{ {
@@ -66,4 +77,18 @@ public sealed class DiscordListSessionsHandlerTests
Assert.Contains("SlashCommand", command, StringComparison.Ordinal); Assert.Contains("SlashCommand", command, StringComparison.Ordinal);
Assert.Contains("listsessions", command, StringComparison.Ordinal); Assert.Contains("listsessions", command, StringComparison.Ordinal);
} }
[Fact]
public void DeleteHandler_ShouldDeleteOnlySessionsFromTheInteractionGuild()
{
var repoRoot = GetRepoRoot();
var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordDeleteSessionHandler.cs");
Assert.True(File.Exists(handlerPath), "DiscordDeleteSessionHandler should exist.");
var handler = File.ReadAllText(handlerPath);
Assert.Contains("DELETE FROM sessions", handler, StringComparison.Ordinal);
Assert.Contains("external_group_id = @GuildId", handler, StringComparison.Ordinal);
Assert.Contains("CanManageSchedule", handler, StringComparison.Ordinal);
}
} }
@@ -21,6 +21,26 @@ public sealed class DiscordSessionInteractionModuleSourceTests
Assert.Contains("MessageFlags.Ephemeral", source, StringComparison.Ordinal); Assert.Contains("MessageFlags.Ephemeral", source, StringComparison.Ordinal);
} }
[Fact]
public async Task Module_ShouldUpdateSourceScheduleMessageThroughComponentInteraction()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
Assert.Contains("InteractionCallback.DeferredModifyMessage", source, StringComparison.Ordinal);
Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal);
Assert.Contains("FollowupAsync", source, StringComparison.Ordinal);
Assert.Contains("CompleteScheduleUpdateResponseAsync", source, StringComparison.Ordinal);
}
[Fact]
public async Task Module_ShouldRouteDeleteSessionButtons()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.DiscordBot/Features/Sessions/DiscordSessionInteractionModule.cs");
Assert.Contains("[ComponentInteraction(\"delete_session\")]", source, StringComparison.Ordinal);
Assert.Contains("DiscordDeleteSessionHandler", source, StringComparison.Ordinal);
}
[Fact] [Fact]
public async Task Module_ShouldRouteRsvpButtonsToNeutralHandler() public async Task Module_ShouldRouteRsvpButtonsToNeutralHandler()
{ {
@@ -13,6 +13,7 @@ public sealed class PlatformNeutralSessionInteractionCommandTests
AssertProperty<JoinSessionCommand>("InteractionId", typeof(string)); AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup)); AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef)); AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertProperty<JoinSessionCommand>("DeferScheduleUpdate", typeof(bool));
AssertNoTelegramSpecificProperties<JoinSessionCommand>(); AssertNoTelegramSpecificProperties<JoinSessionCommand>();
} }
@@ -24,12 +25,29 @@ public sealed class PlatformNeutralSessionInteractionCommandTests
AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string)); AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup)); AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef)); AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertProperty<LeaveSessionCommand>("DeferScheduleUpdate", typeof(bool));
AssertNoTelegramSpecificProperties<LeaveSessionCommand>(); AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
} }
[Fact]
public void SessionInteractionResult_ShouldExposeReplyTextAndUpdatedView()
{
var resultType = typeof(JoinSessionCommand).Assembly.GetType(
"GmRelay.Shared.Features.Sessions.CreateSession.SessionInteractionResult");
Assert.NotNull(resultType);
AssertProperty(resultType, "ReplyText", typeof(string));
AssertProperty(resultType, "UpdatedView", typeof(GmRelay.Shared.Rendering.SessionBatchViewModel));
}
private static void AssertProperty<T>(string name, Type expectedType) private static void AssertProperty<T>(string name, Type expectedType)
{ {
var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name); AssertProperty(typeof(T), name, expectedType);
}
private static void AssertProperty(Type type, string name, Type expectedType)
{
var property = Assert.Single(type.GetProperties(), property => property.Name == name);
Assert.Equal(expectedType, property.PropertyType); Assert.Equal(expectedType, property.PropertyType);
} }
@@ -91,6 +91,15 @@ public sealed class PlatformIdentityMigrationTests
Assert.Contains("platform", service, StringComparison.Ordinal); Assert.Contains("platform", service, StringComparison.Ordinal);
} }
[Fact]
public async Task WebSessionService_ShouldAuthorizeGroupsAcrossLinkedIdentities()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
Assert.Contains("_ResolveLinkedPlayerIdsAsync", service, StringComparison.Ordinal);
Assert.Contains("player_id = ANY(@PlayerIds)", service, StringComparison.Ordinal);
}
[Fact] [Fact]
public async Task AttendanceStatsFunction_ShouldReferenceExternalUsername() public async Task AttendanceStatsFunction_ShouldReferenceExternalUsername()
{ {