feat(platform): route scheduler notifications through platform messenger
PR Checks / test-and-build (pull_request) Successful in 7m9s
PR Checks / test-and-build (pull_request) Successful in 7m9s
This commit is contained in:
+67
-54
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user