feat(discord): add reschedule voting deadline service
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using Npgsql;
|
||||
|
||||
public sealed class DiscordRescheduleVotingDeadlineService(
|
||||
NpgsqlDataSource dataSource,
|
||||
RescheduleVotingFinalizer finalizer,
|
||||
RestClient restClient,
|
||||
ILogger<DiscordRescheduleVotingDeadlineService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessDueProposals(stoppingToken);
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
await ProcessDueProposals(stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
|
||||
}
|
||||
|
||||
private async Task ProcessDueProposals(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var proposalIds = await finalizer.GetDueProposalIdsAsync(ct);
|
||||
foreach (var id in proposalIds)
|
||||
{
|
||||
await TryFinalizeAsync(id, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to process Discord reschedule proposals");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryFinalizeAsync(Guid proposalId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await finalizer.FinalizeAsync(proposalId, ct);
|
||||
if (result is null)
|
||||
return;
|
||||
|
||||
if (result.SourcePlatform != "Discord")
|
||||
return;
|
||||
|
||||
// Update Discord vote message
|
||||
await TryUpdateDiscordVoteMessage(result, ct);
|
||||
|
||||
// If approved, update batch schedule
|
||||
if (result.SelectedOption is not null)
|
||||
{
|
||||
await TryUpdateBatchScheduleAsync(result, ct);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Finalized Discord reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
|
||||
proposalId, result.SessionId, result.Decision.Outcome);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to finalize Discord proposal {ProposalId}", proposalId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryUpdateDiscordVoteMessage(RescheduleVotingFinalizerResult result, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var msgRef = await connection.QuerySingleOrDefaultAsync<PlatformMessageRefDto>(
|
||||
"""
|
||||
SELECT external_channel_id AS ExternalChannelId, external_message_id AS ExternalMessageId
|
||||
FROM platform_messages
|
||||
WHERE session_id = @SessionId AND purpose = 'reschedule_vote' AND platform = 'Discord'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
new { result.SessionId });
|
||||
|
||||
if (msgRef is null)
|
||||
return;
|
||||
|
||||
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
|
||||
result.Title, result.CurrentScheduledAt, result.VotingDeadlineAt,
|
||||
result.Options, result.Participants, result.Votes);
|
||||
|
||||
var channelId = ulong.Parse(msgRef.ExternalChannelId);
|
||||
var messageId = ulong.Parse(msgRef.ExternalMessageId);
|
||||
|
||||
// Disable buttons after finalization
|
||||
var disabledRow = new ActionRowProperties();
|
||||
foreach (var btn in actionRow.OfType<ButtonProperties>())
|
||||
{
|
||||
disabledRow.Add(new ButtonProperties(btn.CustomId, btn.Label ?? string.Empty, ButtonStyle.Secondary) { Disabled = true });
|
||||
}
|
||||
|
||||
var resultText = result.SelectedOption is not null
|
||||
? $"Голосование завершено. Победил вариант {result.SelectedOption.DisplayOrder}: **{result.SelectedOption.ProposedAt.FormatMoscow()}** (МСК)."
|
||||
: $"Голосование завершено. {result.Decision.Reason}";
|
||||
|
||||
var updatedEmbed = embed.WithDescription($"{embed.Description}\n\n{resultText}");
|
||||
|
||||
await restClient.ModifyMessageAsync(channelId, messageId, options =>
|
||||
{
|
||||
options.Embeds = new[] { updatedEmbed };
|
||||
options.Components = new[] { disabledRow };
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update Discord vote message for session {SessionId}", result.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryUpdateBatchScheduleAsync(RescheduleVotingFinalizerResult result, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Query batch schedule message ref
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var batchRef = await connection.QuerySingleOrDefaultAsync<PlatformMessageRefDto>(
|
||||
"""
|
||||
SELECT external_channel_id AS ExternalChannelId, external_message_id AS ExternalMessageId
|
||||
FROM platform_messages
|
||||
WHERE batch_id = @BatchId AND purpose = 'schedule' AND platform = 'Discord'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
new { result.BatchId });
|
||||
|
||||
if (batchRef is null)
|
||||
return;
|
||||
|
||||
// Rebuild schedule view and update Discord message
|
||||
var sessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||
new { result.BatchId })).ToList();
|
||||
|
||||
var participants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, COALESCE(p.external_username, p.telegram_username) AS TelegramUsername, sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
JOIN sessions s ON sp.session_id = s.id
|
||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||
ORDER BY sp.registration_status ASC, sp.created_at ASC
|
||||
""",
|
||||
new { result.BatchId })).ToList();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build(result.Title, sessions, participants);
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
|
||||
|
||||
var channelId = ulong.Parse(batchRef.ExternalChannelId);
|
||||
var messageId = ulong.Parse(batchRef.ExternalMessageId);
|
||||
|
||||
await restClient.ModifyMessageAsync(channelId, messageId, options =>
|
||||
{
|
||||
options.Embeds = embeds;
|
||||
options.Components = actionRows;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update Discord batch schedule for session {SessionId}", result.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PlatformMessageRefDto(string ExternalChannelId, string ExternalMessageId);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace GmRelay.DiscordBot.Infrastructure;
|
||||
|
||||
public sealed class SystemClock : GmRelay.Shared.Platform.ISystemClock
|
||||
{
|
||||
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using GmRelay.DiscordBot;
|
||||
using GmRelay.DiscordBot.Features.Sessions;
|
||||
using GmRelay.DiscordBot.Infrastructure;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.DiscordBot.Infrastructure.Logging;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -51,6 +53,9 @@ builder.Services.AddSingleton<JoinSessionHandler>();
|
||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordInteractionReplyCache>();
|
||||
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
|
||||
builder.Services.AddSingleton<ISystemClock, SystemClock>();
|
||||
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
|
||||
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
|
||||
|
||||
builder.Services
|
||||
.AddDiscordGateway(options =>
|
||||
|
||||
Reference in New Issue
Block a user