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<DiscordPermissionChecker>();
|
||||||
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
||||||
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
||||||
|
builder.Services.AddSingleton<DiscordRescheduleHandler>();
|
||||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||||
|
|||||||
Reference in New Issue
Block a user