feat(platform): route scheduler notifications through platform messenger
PR Checks / test-and-build (pull_request) Successful in 7m9s

This commit is contained in:
2026-05-21 12:30:35 +03:00
parent 5dbec1a0a4
commit 2a707e4825
49 changed files with 2158 additions and 846 deletions
@@ -1,19 +1,15 @@
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,
IPlatformMessenger messenger,
ILogger<DiscordRescheduleVotingDeadlineService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -27,7 +23,9 @@ public sealed class DiscordRescheduleVotingDeadlineService(
await ProcessDueProposals(stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
}
private async Task ProcessDueProposals(CancellationToken ct)
@@ -57,10 +55,8 @@ public sealed class DiscordRescheduleVotingDeadlineService(
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);
@@ -68,7 +64,9 @@ public sealed class DiscordRescheduleVotingDeadlineService(
logger.LogInformation(
"Finalized Discord reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
proposalId, result.SessionId, result.Decision.Outcome);
proposalId,
result.SessionId,
result.Decision.Outcome);
}
catch (Exception ex)
{
@@ -83,10 +81,13 @@ public sealed class DiscordRescheduleVotingDeadlineService(
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
SELECT g.external_group_id AS ExternalGroupId,
COALESCE(pm.external_channel_id, g.external_channel_id, g.external_group_id) AS ExternalChannelId,
pm.external_message_id AS ExternalMessageId
FROM platform_messages pm
JOIN game_groups g ON g.id = pm.group_id
WHERE pm.session_id = @SessionId AND pm.purpose = 'reschedule_vote' AND pm.platform = 'Discord'
ORDER BY pm.created_at DESC
LIMIT 1
""",
new { result.SessionId });
@@ -94,31 +95,27 @@ public sealed class DiscordRescheduleVotingDeadlineService(
if (msgRef is null)
return;
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
result.Title, result.CurrentScheduledAt, result.VotingDeadlineAt,
result.Options, result.Participants, result.Votes);
var group = CreateDiscordGroup(msgRef);
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 };
});
await messenger.UpdateRescheduleVoteAsync(
new PlatformRescheduleVoteUpdate(
group,
new PlatformMessageRef(
PlatformKind.Discord,
msgRef.ExternalGroupId,
null,
msgRef.ExternalMessageId),
result.ProposalId,
result.SessionId,
result.Title,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Decision,
result.SelectedOption,
result.Options,
result.Votes,
result.Participants),
ct);
}
catch (Exception ex)
{
@@ -130,14 +127,16 @@ public sealed class DiscordRescheduleVotingDeadlineService(
{
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
SELECT g.external_group_id AS ExternalGroupId,
COALESCE(pm.external_channel_id, g.external_channel_id, g.external_group_id) AS ExternalChannelId,
pm.external_message_id AS ExternalMessageId
FROM platform_messages pm
JOIN game_groups g ON g.id = pm.group_id
WHERE pm.batch_id = @BatchId AND pm.purpose = 'schedule' AND pm.platform = 'Discord'
ORDER BY pm.created_at DESC
LIMIT 1
""",
new { result.BatchId });
@@ -145,14 +144,16 @@ public sealed class DiscordRescheduleVotingDeadlineService(
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
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
@@ -162,16 +163,18 @@ public sealed class DiscordRescheduleVotingDeadlineService(
new { result.BatchId })).ToList();
var view = SessionBatchViewBuilder.Build(result.Title, sessions, participants);
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
var group = CreateDiscordGroup(batchRef);
var channelId = ulong.Parse(batchRef.ExternalChannelId);
var messageId = ulong.Parse(batchRef.ExternalMessageId);
await restClient.ModifyMessageAsync(channelId, messageId, options =>
{
options.Embeds = embeds;
options.Components = actionRows;
});
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
group,
view,
new PlatformMessageRef(
PlatformKind.Discord,
batchRef.ExternalGroupId,
null,
batchRef.ExternalMessageId)),
ct);
}
catch (Exception ex)
{
@@ -179,5 +182,15 @@ public sealed class DiscordRescheduleVotingDeadlineService(
}
}
internal sealed record PlatformMessageRefDto(string ExternalChannelId, string ExternalMessageId);
private static PlatformGroup CreateDiscordGroup(PlatformMessageRefDto message) =>
new(
PlatformKind.Discord,
message.ExternalGroupId,
message.ExternalGroupId,
message.ExternalChannelId);
internal sealed record PlatformMessageRefDto(
string ExternalGroupId,
string ExternalChannelId,
string ExternalMessageId);
}
@@ -1,5 +1,9 @@
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
using System.Globalization;
using NetCord;
using NetCord.Rest;
using NetCord.Services.ComponentInteractions;
@@ -9,6 +13,7 @@ namespace GmRelay.DiscordBot.Features.Sessions;
public sealed class DiscordSessionInteractionModule(
JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
HandleRsvpHandler rsvpHandler,
DiscordRescheduleVoteHandler voteHandler,
DiscordInteractionReplyCache interactionReplies,
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
@@ -67,6 +72,65 @@ public sealed class DiscordSessionInteractionModule(
await CompleteWithStoredReplyAsync(input.InteractionId);
}
[ComponentInteraction("rsvp")]
public async Task RsvpAsync(string status, string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var rsvpStatus = status switch
{
"confirm" => RsvpStatus.Confirmed,
"decline" => RsvpStatus.Declined,
_ => null
};
if (rsvpStatus is null)
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
try
{
await rsvpHandler.HandleAsync(
new HandleRsvpCommand(
parsedSessionId,
new PlatformUser(
PlatformKind.Discord,
Context.User.Id.ToString(CultureInfo.InvariantCulture),
string.IsNullOrWhiteSpace(Context.User.GlobalName) ? Context.User.Username : Context.User.GlobalName,
Context.User.Username),
rsvpStatus,
input.InteractionId,
new PlatformGroup(
PlatformKind.Discord,
input.GuildId,
input.GuildId,
input.ChannelId),
new PlatformMessageRef(
PlatformKind.Discord,
input.GuildId,
null,
input.MessageId)),
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
}
[ComponentInteraction("reschedule_vote")]
public async Task RescheduleVoteAsync(string optionId)
{
@@ -112,9 +176,9 @@ public sealed class DiscordSessionInteractionModule(
return new DiscordSessionInteractionInput(
SessionId: sessionId,
InteractionId: Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
GuildId: guild.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
ChannelId: Context.Channel.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
MessageId: message.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
GuildId: guild.Id.ToString(CultureInfo.InvariantCulture),
ChannelId: Context.Channel.Id.ToString(CultureInfo.InvariantCulture),
MessageId: message.Id.ToString(CultureInfo.InvariantCulture),
UserId: Context.User.Id,
Username: Context.User.Username,
DisplayName: Context.User.GlobalName);