feat(discord): add reschedule vote button handler
This commit is contained in:
@@ -14,13 +14,6 @@ public sealed record HandleRescheduleVoteCommand(
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
internal sealed record VoteProposalDto(
|
||||
Guid Id,
|
||||
Guid SessionId,
|
||||
DateTimeOffset VotingDeadlineAt,
|
||||
string Title,
|
||||
DateTime CurrentScheduledAt);
|
||||
|
||||
public sealed class HandleRescheduleVoteHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
|
||||
@@ -149,7 +149,8 @@ public sealed class DiscordRescheduleHandler(
|
||||
return new DiscordRescheduleResult(proposalId, optionDtos, deadline);
|
||||
}
|
||||
|
||||
private static (EmbedProperties Embed, ActionRowProperties ActionRow) BuildVoteMessage(
|
||||
// internal for now — temporary duplication until DiscordRescheduleVotingRenderer is extracted in Task 6
|
||||
internal static (EmbedProperties Embed, ActionRowProperties ActionRow) BuildVoteMessage(
|
||||
string title, DateTime currentTime, DateTimeOffset deadline,
|
||||
IReadOnlyList<RescheduleOptionDto> options, IReadOnlyList<VoteParticipantDto> participants, IReadOnlyList<RescheduleOptionVoteDto> votes)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
using NetCord.Rest;
|
||||
|
||||
public sealed record DiscordRescheduleVoteInput(
|
||||
Guid OptionId, ulong UserId, string InteractionId,
|
||||
string GuildId, string ChannelId, string MessageId);
|
||||
|
||||
public sealed class DiscordRescheduleVoteHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
RestClient restClient,
|
||||
ILogger<DiscordRescheduleVoteHandler> logger)
|
||||
{
|
||||
public async Task<string> HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// 1. Load proposal + option
|
||||
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
|
||||
"""
|
||||
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt,
|
||||
s.title AS Title, s.scheduled_at AS CurrentScheduledAt
|
||||
FROM reschedule_options ro
|
||||
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
|
||||
JOIN sessions s ON s.id = rp.session_id
|
||||
WHERE ro.id = @OptionId AND rp.status = 'Voting'
|
||||
""",
|
||||
new { input.OptionId },
|
||||
transaction);
|
||||
|
||||
if (proposal is null)
|
||||
return "Голосование уже завершено или не найдено.";
|
||||
|
||||
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
|
||||
return "Дедлайн уже прошёл. Результаты скоро будут применены.";
|
||||
|
||||
// 2. Verify participant (Discord platform)
|
||||
var playerId = await connection.ExecuteScalarAsync<Guid?>(
|
||||
"""
|
||||
SELECT p.id
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND p.platform = 'Discord'
|
||||
AND p.external_user_id = @UserId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
""",
|
||||
new { proposal.SessionId, UserId = input.UserId.ToString(), Active = ParticipantRegistrationStatus.Active },
|
||||
transaction);
|
||||
|
||||
if (playerId is null)
|
||||
return "Вы не являетесь участником этой сессии.";
|
||||
|
||||
// 3. Upsert vote
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
|
||||
VALUES (@ProposalId, @PlayerId, @OptionId)
|
||||
ON CONFLICT (proposal_id, player_id) DO UPDATE
|
||||
SET option_id = EXCLUDED.option_id, voted_at = now()
|
||||
""",
|
||||
new { ProposalId = proposal.Id, PlayerId = playerId.Value, input.OptionId },
|
||||
transaction);
|
||||
|
||||
// 4. Reload participants, options, votes for re-rendering
|
||||
var participants = (await connection.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
|
||||
ORDER BY p.display_name
|
||||
""",
|
||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction)).ToList();
|
||||
|
||||
var options = (await connection.QueryAsync<RescheduleOptionDto>(
|
||||
"""
|
||||
SELECT id AS OptionId, display_order AS DisplayOrder, proposed_at AS ProposedAt
|
||||
FROM reschedule_options
|
||||
WHERE proposal_id = @ProposalId
|
||||
ORDER BY display_order
|
||||
""",
|
||||
new { ProposalId = proposal.Id },
|
||||
transaction)).ToList();
|
||||
|
||||
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
|
||||
"""
|
||||
SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername
|
||||
FROM reschedule_option_votes rov
|
||||
JOIN players p ON p.id = rov.player_id
|
||||
WHERE rov.proposal_id = @ProposalId
|
||||
ORDER BY rov.voted_at, p.display_name
|
||||
""",
|
||||
new { ProposalId = proposal.Id },
|
||||
transaction)).ToList();
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
// 5. Re-render and update Discord vote message
|
||||
var (embed, actionRow) = DiscordRescheduleHandler.BuildVoteMessage(
|
||||
proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt,
|
||||
options, participants, votes);
|
||||
|
||||
var channelIdUlong = ulong.Parse(input.ChannelId);
|
||||
var messageIdUlong = ulong.Parse(input.MessageId);
|
||||
|
||||
try
|
||||
{
|
||||
await restClient.ModifyMessageAsync(channelIdUlong, messageIdUlong, options =>
|
||||
{
|
||||
options.Embeds = new[] { embed };
|
||||
options.Components = new[] { actionRow };
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id);
|
||||
}
|
||||
|
||||
return "Ваш голос учтён. До дедлайна его можно изменить.";
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
public sealed class DiscordSessionInteractionModule(
|
||||
JoinSessionHandler joinSessionHandler,
|
||||
LeaveSessionHandler leaveSessionHandler,
|
||||
DiscordRescheduleVoteHandler voteHandler,
|
||||
DiscordInteractionReplyCache interactionReplies,
|
||||
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
|
||||
{
|
||||
@@ -66,6 +67,41 @@ public sealed class DiscordSessionInteractionModule(
|
||||
await CompleteWithStoredReplyAsync(input.InteractionId);
|
||||
}
|
||||
|
||||
[ComponentInteraction("reschedule_vote")]
|
||||
public async Task RescheduleVoteAsync(string optionId)
|
||||
{
|
||||
if (!Guid.TryParse(optionId, out var parsedOptionId))
|
||||
{
|
||||
await RespondAsync(CreateEphemeralReply("Vote button is outdated."));
|
||||
return;
|
||||
}
|
||||
|
||||
var input = CreateInput(Guid.Empty); // sessionId not needed for vote routing
|
||||
var voteInput = new DiscordRescheduleVoteInput(
|
||||
parsedOptionId,
|
||||
Context.User.Id,
|
||||
Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
input.GuildId,
|
||||
input.ChannelId,
|
||||
input.MessageId);
|
||||
|
||||
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
||||
|
||||
string replyText;
|
||||
try
|
||||
{
|
||||
replyText = await voteHandler.HandleAsync(voteInput, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord reschedule vote for option {OptionId}", parsedOptionId);
|
||||
await CompleteResponseAsync("Не удалось обработать голос.");
|
||||
return;
|
||||
}
|
||||
|
||||
await CompleteResponseAsync(replyText);
|
||||
}
|
||||
|
||||
private DiscordSessionInteractionInput CreateInput(Guid sessionId)
|
||||
{
|
||||
var guild = Context.Guild
|
||||
|
||||
@@ -45,6 +45,7 @@ builder.Services.AddSingleton<DiscordPermissionChecker>();
|
||||
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
||||
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordRescheduleHandler>();
|
||||
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
|
||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||
|
||||
@@ -5,6 +5,13 @@ public sealed record RescheduleOptionDto(
|
||||
int DisplayOrder,
|
||||
DateTimeOffset ProposedAt);
|
||||
|
||||
public sealed record VoteProposalDto(
|
||||
Guid Id,
|
||||
Guid SessionId,
|
||||
DateTimeOffset VotingDeadlineAt,
|
||||
string Title,
|
||||
DateTime CurrentScheduledAt);
|
||||
|
||||
public sealed record RescheduleOptionVoteDto(
|
||||
Guid OptionId,
|
||||
Guid PlayerId,
|
||||
|
||||
Reference in New Issue
Block a user