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;
|
||||||
using GmRelay.DiscordBot.Features.Sessions;
|
using GmRelay.DiscordBot.Features.Sessions;
|
||||||
|
using GmRelay.DiscordBot.Infrastructure;
|
||||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||||
using GmRelay.DiscordBot.Infrastructure.Logging;
|
using GmRelay.DiscordBot.Infrastructure.Logging;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -51,6 +53,9 @@ builder.Services.AddSingleton<JoinSessionHandler>();
|
|||||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||||
builder.Services.AddSingleton<DiscordInteractionReplyCache>();
|
builder.Services.AddSingleton<DiscordInteractionReplyCache>();
|
||||||
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
|
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
|
||||||
|
builder.Services.AddSingleton<ISystemClock, SystemClock>();
|
||||||
|
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
|
||||||
|
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddDiscordGateway(options =>
|
.AddDiscordGateway(options =>
|
||||||
|
|||||||
Reference in New Issue
Block a user