From e93e777fb38379584364d3e2ffb2aa1547160635 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 12:02:26 +0300 Subject: [PATCH] feat(discord): add /reschedule slash command and handler --- .../Sessions/DiscordRescheduleCommand.cs | 117 +++++++++++ .../Sessions/DiscordRescheduleHandler.cs | 192 ++++++++++++++++++ src/GmRelay.DiscordBot/Program.cs | 1 + 3 files changed, 310 insertions(+) create mode 100644 src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs create mode 100644 src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs new file mode 100644 index 0000000..df31f7c --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleCommand.cs @@ -0,0 +1,117 @@ +namespace GmRelay.DiscordBot.Features.Sessions; + +using NetCord.Rest; +using NetCord.Services.ApplicationCommands; + +[SlashCommand("reschedule", "Initiate reschedule voting for a session")] +public class DiscordRescheduleCommand : ApplicationCommandModule +{ + private readonly DiscordRescheduleHandler _handler; + private readonly ILogger _logger; + + public DiscordRescheduleCommand(DiscordRescheduleHandler handler, ILogger logger) + { + _handler = handler; + _logger = logger; + } + + public async Task ExecuteAsync( + [SlashCommandParameter(Name = "session", Description = "Session ID to reschedule")] string sessionIdText, + [SlashCommandParameter(Name = "option1", Description = "First time option (YYYY-MM-DD HH:mm)")] string option1, + [SlashCommandParameter(Name = "option2", Description = "Second time option (YYYY-MM-DD HH:mm)")] string option2, + [SlashCommandParameter(Name = "option3", Description = "Third time option (optional)")] string? option3 = null, + [SlashCommandParameter(Name = "deadline", Description = "Voting deadline (YYYY-MM-DD HH:mm)")] string deadline = "") + { + var guild = Context.Guild + ?? throw new InvalidOperationException("This command can only be used in a guild."); + + if (!Guid.TryParse(sessionIdText, out var sessionId)) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message("❌ Некорректный ID сессии.")); + return; + } + + var options = new List { option1, option2 }; + if (!string.IsNullOrWhiteSpace(option3)) + options.Add(option3); + + var parsedOptions = new List(); + foreach (var opt in options) + { + var result = DiscordNewSessionHandler.ParseTimeInput(opt); + if (!result.IsSuccess) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message($"❌ {opt}: {result.Error}")); + return; + } + parsedOptions.Add(result.Value); + } + + var deadlineResult = DiscordNewSessionHandler.ParseTimeInput(deadline); + if (!deadlineResult.IsSuccess) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message($"❌ Дедлайн: {deadlineResult.Error}")); + return; + } + + if (deadlineResult.Value >= parsedOptions.Min()) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message("❌ Дедлайн должен быть раньше первого варианта времени.")); + return; + } + + var resolvedPermissions = GetResolvedPermissions(guild, Context.User.Id); + + try + { + var result = await _handler.HandleAsync( + guildId: guild.Id.ToString(), + channelId: Context.Channel.Id.ToString(), + userId: Context.User.Id, + userDisplayName: Context.User.GlobalName ?? Context.User.Username, + resolvedPermissions: resolvedPermissions, + guildOwnerId: guild.OwnerId, + sessionId: sessionId, + options: parsedOptions, + deadline: deadlineResult.Value, + CancellationToken.None); + + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message( + $"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC.")); + } + catch (UnauthorizedAccessException ex) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message($":no_entry: {ex.Message}")); + } + catch (InvalidOperationException ex) + { + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message($":warning: {ex.Message}")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initiate reschedule for session {SessionId}", sessionId); + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message(":boom: Ошибка при запуске голосования.")); + } + } + + private static ulong GetResolvedPermissions(NetCord.Gateway.Guild guild, ulong userId) + { + if (!guild.Users.TryGetValue(userId, out var guildUser)) + return 0; + ulong resolved = 0; + foreach (var roleId in guildUser.RoleIds) + { + if (guild.Roles.TryGetValue(roleId, out var role)) + resolved |= (ulong)role.Permissions; + } + return resolved; + } +} diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs new file mode 100644 index 0000000..9ace0e2 --- /dev/null +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs @@ -0,0 +1,192 @@ +namespace GmRelay.DiscordBot.Features.Sessions; + +using Dapper; +using GmRelay.DiscordBot.Infrastructure.Discord; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; +using GmRelay.Shared.Platform; +using NetCord; +using NetCord.Rest; +using Npgsql; + +public sealed record DiscordRescheduleResult(Guid ProposalId, IReadOnlyList Options, DateTimeOffset Deadline); + +public sealed class DiscordRescheduleHandler( + NpgsqlDataSource dataSource, + DiscordPermissionChecker permissionChecker, + RestClient restClient, + ILogger logger) +{ + public async Task HandleAsync( + string guildId, + string channelId, + ulong userId, + string userDisplayName, + ulong resolvedPermissions, + ulong guildOwnerId, + Guid sessionId, + IReadOnlyList options, + DateTimeOffset deadline, + CancellationToken ct) + { + // 1. Permission check + read-only validation (before Discord message) + await using var readConnection = await dataSource.OpenConnectionAsync(ct); + + var dbManagerUserIds = await readConnection.QueryAsync( + @"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)) + { + throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут переносить сессии."); + } + + // 2. Ensure player exists + await readConnection.ExecuteAsync( + @"INSERT INTO players (display_name, platform, external_user_id, external_username) + VALUES (@Name, 'Discord', @UserId, @Name) + ON CONFLICT (platform, external_user_id) + WHERE platform IS NOT NULL AND external_user_id IS NOT NULL + DO UPDATE SET display_name = EXCLUDED.display_name", + new { Name = userDisplayName, UserId = userId.ToString() }); + + // 3. Verify session exists + var session = await readConnection.QuerySingleOrDefaultAsync( + """ + SELECT s.title AS Title, s.scheduled_at AS CurrentScheduledAt + FROM sessions s + WHERE s.id = @SessionId AND s.status != @Cancelled + """, + new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled }); + + if (session is null) + throw new InvalidOperationException("Сессия не найдена или отменена."); + + // 4. Check no active proposal + var hasActive = await readConnection.ExecuteScalarAsync( + "SELECT EXISTS (SELECT 1 FROM reschedule_proposals WHERE session_id = @SessionId AND status IN ('AwaitingTime', 'Voting'))", + new { SessionId = sessionId }); + + if (hasActive) + throw new InvalidOperationException("Уже есть активный запрос на перенос этой сессии."); + + // 5. Load participants for rendering + var participants = (await readConnection.QueryAsync( + """ + SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId + FROM session_participants sp + JOIN players p ON p.id = sp.player_id + WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active + """, + new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); + + // 6. Prepare proposal data + var proposalId = Guid.NewGuid(); + var optionDtos = options.Select((o, i) => new RescheduleOptionDto(Guid.NewGuid(), i + 1, o)).ToList(); + + // 7. Build and send Discord vote message BEFORE transaction + var (embed, actionRow) = BuildVoteMessage(session.Title, session.CurrentScheduledAt, deadline, optionDtos, participants, []); + + var channelIdUlong = ulong.Parse(channelId); + + // NOTE: Discord message is sent before DB transaction to avoid orphaned proposals + // if the send fails. There is a negligible race window where the message is visible + // before the DB commit; in practice users cannot click faster than the transaction commits. + var sentMessage = await restClient.SendMessageAsync( + channelIdUlong, + new MessageProperties() + .WithEmbeds(new[] { embed }) + .WithComponents(new[] { actionRow })); + + // 8. Create proposal + options + platform_messages in transaction + try + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + await using var transaction = await connection.BeginTransactionAsync(ct); + + await connection.ExecuteAsync( + """ + INSERT INTO reschedule_proposals (id, session_id, proposed_by, source_platform, proposed_by_external_user_id, status, voting_deadline_at) + VALUES (@Id, @SessionId, NULL, 'Discord', @ProposedBy, 'Voting', @Deadline) + """, + new { Id = proposalId, SessionId = sessionId, ProposedBy = userId.ToString(), Deadline = deadline.UtcDateTime }, + transaction); + + foreach (var option in optionDtos) + { + await connection.ExecuteAsync( + """ + INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order) + VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder) + """, + new { option.OptionId, ProposalId = proposalId, option.ProposedAt, option.DisplayOrder }, + transaction); + } + + await connection.ExecuteAsync( + """ + INSERT INTO platform_messages (platform, group_id, session_id, external_channel_id, external_message_id, purpose) + VALUES ('Discord', (SELECT id FROM game_groups WHERE platform = 'Discord' AND external_group_id = @GuildId), @SessionId, @ChannelId, @MessageId, 'reschedule_vote') + """, + new { GuildId = guildId, SessionId = sessionId, ChannelId = channelId, MessageId = sentMessage.Id.ToString() }, + transaction); + + await transaction.CommitAsync(ct); + } + catch (Exception ex) + { + logger.LogError(ex, "Transaction failed after Discord message sent; deleting orphaned message"); + try { await restClient.DeleteMessageAsync(channelIdUlong, sentMessage.Id); } catch { /* best effort */ } + throw; + } + + logger.LogInformation("Discord reschedule voting started for session {SessionId}, proposal {ProposalId}", sessionId, proposalId); + + return new DiscordRescheduleResult(proposalId, optionDtos, deadline); + } + + private static (EmbedProperties Embed, ActionRowProperties ActionRow) BuildVoteMessage( + string title, DateTime currentTime, DateTimeOffset deadline, + IReadOnlyList options, IReadOnlyList participants, IReadOnlyList votes) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)"); + sb.AppendLine($"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)"); + sb.AppendLine(); + sb.AppendLine("Выберите один из вариантов:"); + + foreach (var option in options.OrderBy(o => o.DisplayOrder)) + { + var count = votes.Count(v => v.OptionId == option.OptionId); + sb.AppendLine($"{option.DisplayOrder}. **{option.ProposedAt.FormatMoscow()}** (МСК) — {count} голосов"); + } + + if (participants.Count > 0) + { + sb.AppendLine(); + sb.AppendLine($"Голосов: {votes.Count}/{participants.Count}"); + } + + var embed = new EmbedProperties() + .WithTitle($"🔄 Перенос сессии «{title}»") + .WithDescription(sb.ToString()) + .WithColor(new NetCord.Color(0xFEE75C)); + + var actionRow = new ActionRowProperties(); + foreach (var option in options.OrderBy(o => o.DisplayOrder)) + { + actionRow.Add(new ButtonProperties( + $"reschedule_vote:{option.OptionId}", + $"{option.DisplayOrder}. {option.ProposedAt.ToOffset(TimeSpan.FromHours(3)):dd.MM HH:mm}", + ButtonStyle.Primary)); + } + + return (embed, actionRow); + } +} + +internal sealed record RescheduleSessionInfoDto(string Title, DateTime CurrentScheduledAt); diff --git a/src/GmRelay.DiscordBot/Program.cs b/src/GmRelay.DiscordBot/Program.cs index 79ddff8..1e7f15a 100644 --- a/src/GmRelay.DiscordBot/Program.cs +++ b/src/GmRelay.DiscordBot/Program.cs @@ -44,6 +44,7 @@ builder.Services.AddSingleton(sp => builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton();