feat(discord): add reschedule vote button handler
This commit is contained in:
@@ -14,13 +14,6 @@ public sealed record HandleRescheduleVoteCommand(
|
|||||||
long ChatId,
|
long ChatId,
|
||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
internal sealed record VoteProposalDto(
|
|
||||||
Guid Id,
|
|
||||||
Guid SessionId,
|
|
||||||
DateTimeOffset VotingDeadlineAt,
|
|
||||||
string Title,
|
|
||||||
DateTime CurrentScheduledAt);
|
|
||||||
|
|
||||||
public sealed class HandleRescheduleVoteHandler(
|
public sealed class HandleRescheduleVoteHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
|
|||||||
@@ -149,7 +149,8 @@ public sealed class DiscordRescheduleHandler(
|
|||||||
return new DiscordRescheduleResult(proposalId, optionDtos, deadline);
|
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,
|
string title, DateTime currentTime, DateTimeOffset deadline,
|
||||||
IReadOnlyList<RescheduleOptionDto> options, IReadOnlyList<VoteParticipantDto> participants, IReadOnlyList<RescheduleOptionVoteDto> votes)
|
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(
|
public sealed class DiscordSessionInteractionModule(
|
||||||
JoinSessionHandler joinSessionHandler,
|
JoinSessionHandler joinSessionHandler,
|
||||||
LeaveSessionHandler leaveSessionHandler,
|
LeaveSessionHandler leaveSessionHandler,
|
||||||
|
DiscordRescheduleVoteHandler voteHandler,
|
||||||
DiscordInteractionReplyCache interactionReplies,
|
DiscordInteractionReplyCache interactionReplies,
|
||||||
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
|
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
|
||||||
{
|
{
|
||||||
@@ -66,6 +67,41 @@ public sealed class DiscordSessionInteractionModule(
|
|||||||
await CompleteWithStoredReplyAsync(input.InteractionId);
|
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)
|
private DiscordSessionInteractionInput CreateInput(Guid sessionId)
|
||||||
{
|
{
|
||||||
var guild = Context.Guild
|
var guild = Context.Guild
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ 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<DiscordRescheduleHandler>();
|
||||||
|
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
|
||||||
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>();
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ public sealed record RescheduleOptionDto(
|
|||||||
int DisplayOrder,
|
int DisplayOrder,
|
||||||
DateTimeOffset ProposedAt);
|
DateTimeOffset ProposedAt);
|
||||||
|
|
||||||
|
public sealed record VoteProposalDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid SessionId,
|
||||||
|
DateTimeOffset VotingDeadlineAt,
|
||||||
|
string Title,
|
||||||
|
DateTime CurrentScheduledAt);
|
||||||
|
|
||||||
public sealed record RescheduleOptionVoteDto(
|
public sealed record RescheduleOptionVoteDto(
|
||||||
Guid OptionId,
|
Guid OptionId,
|
||||||
Guid PlayerId,
|
Guid PlayerId,
|
||||||
|
|||||||
Reference in New Issue
Block a user