feat(discord): add /reschedule slash command and handler
This commit is contained in:
@@ -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<SlashCommandContext>
|
||||
{
|
||||
private readonly DiscordRescheduleHandler _handler;
|
||||
private readonly ILogger<DiscordRescheduleCommand> _logger;
|
||||
|
||||
public DiscordRescheduleCommand(DiscordRescheduleHandler handler, ILogger<DiscordRescheduleCommand> 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<string> { option1, option2 };
|
||||
if (!string.IsNullOrWhiteSpace(option3))
|
||||
options.Add(option3);
|
||||
|
||||
var parsedOptions = new List<DateTimeOffset>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<RescheduleOptionDto> Options, DateTimeOffset Deadline);
|
||||
|
||||
public sealed class DiscordRescheduleHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordPermissionChecker permissionChecker,
|
||||
RestClient restClient,
|
||||
ILogger<DiscordRescheduleHandler> logger)
|
||||
{
|
||||
public async Task<DiscordRescheduleResult> HandleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
ulong userId,
|
||||
string userDisplayName,
|
||||
ulong resolvedPermissions,
|
||||
ulong guildOwnerId,
|
||||
Guid sessionId,
|
||||
IReadOnlyList<DateTimeOffset> 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<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))
|
||||
{
|
||||
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<RescheduleSessionInfoDto>(
|
||||
"""
|
||||
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<bool>(
|
||||
"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<VoteParticipantDto>(
|
||||
"""
|
||||
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<RescheduleOptionDto> options, IReadOnlyList<VoteParticipantDto> participants, IReadOnlyList<RescheduleOptionVoteDto> 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);
|
||||
@@ -44,6 +44,7 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||
builder.Services.AddSingleton<DiscordPermissionChecker>();
|
||||
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
||||
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordRescheduleHandler>();
|
||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||
|
||||
Reference in New Issue
Block a user