refactor: extract remaining Telegram handlers to platform-neutral contracts
PR Checks / test-and-build (pull_request) Successful in 13m48s
PR Checks / test-and-build (pull_request) Successful in 13m48s
- Extract CreateSessionHandler, ListSessionsHandler, DeleteSessionHandler, ExportCalendarHandler, HandleRescheduleTimeInputHandler, HandleRescheduleVoteHandler to GmRelay.Shared - Add IPlatformMessenger methods: SendScheduleAsync, UpdateScheduleAsync, SendGroupMessageAsync with actions, CreateThreadAsync, DeleteThreadAsync - Rewrite Telegram Bot wrappers as thin adapters delegating to shared handlers - Rewrite DiscordRescheduleVoteHandler to use shared HandleRescheduleVoteHandler - Update UpdateRouter with explicit type aliases for ambiguous handler names - Add contract and source-inspection tests for extracted handlers - Bump version 3.1.1 → 3.2.0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,114 +1,46 @@
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
using NetCord.Rest;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public sealed record DiscordRescheduleVoteInput(
|
||||
Guid OptionId, ulong UserId, string InteractionId,
|
||||
string GuildId, string ChannelId, string MessageId);
|
||||
Guid OptionId,
|
||||
ulong UserId,
|
||||
string InteractionId,
|
||||
string GuildId,
|
||||
string ChannelId,
|
||||
string MessageId);
|
||||
|
||||
public sealed class DiscordRescheduleVoteHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
|
||||
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);
|
||||
var command = new HandleRescheduleVoteCommand(
|
||||
input.OptionId,
|
||||
new PlatformUser(PlatformKind.Discord, input.UserId.ToString(), string.Empty, null),
|
||||
new PlatformGroup(PlatformKind.Discord, input.GuildId, string.Empty, input.ChannelId),
|
||||
input.InteractionId,
|
||||
new PlatformMessageRef(PlatformKind.Discord, input.ChannelId, null, input.MessageId));
|
||||
|
||||
// 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);
|
||||
var result = await sharedHandler.HandleAsync(command, ct);
|
||||
|
||||
if (proposal is null)
|
||||
return "Голосование уже завершено или не найдено.";
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.ReplyText!;
|
||||
}
|
||||
|
||||
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) = DiscordRescheduleVotingRenderer.Render(
|
||||
proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt,
|
||||
options, participants, votes);
|
||||
result.Title!,
|
||||
result.CurrentScheduledAt,
|
||||
result.VotingDeadlineAt,
|
||||
result.Options,
|
||||
result.Participants,
|
||||
result.Votes);
|
||||
|
||||
var channelIdUlong = ulong.Parse(input.ChannelId);
|
||||
var messageIdUlong = ulong.Parse(input.MessageId);
|
||||
@@ -123,9 +55,9 @@ public sealed class DiscordRescheduleVoteHandler(
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id);
|
||||
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", result.ProposalId);
|
||||
}
|
||||
|
||||
return "Ваш голос учтён. До дедлайна его можно изменить.";
|
||||
return result.ReplyText!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,38 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
||||
await restClient.SendMessageAsync(GetChannelId(group), htmlText);
|
||||
}
|
||||
|
||||
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
|
||||
{
|
||||
var rows = BuildActionRows(actions);
|
||||
await restClient.SendMessageAsync(GetChannelId(group), new MessageProperties().WithContent(htmlText).WithComponents(rows));
|
||||
}
|
||||
|
||||
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
|
||||
{
|
||||
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
|
||||
var messageId = ParseSnowflake(messageRef.ExternalMessageId);
|
||||
var rows = BuildActionRows(actions);
|
||||
await restClient.ModifyMessageAsync(channelId, messageId, options =>
|
||||
{
|
||||
options.Content = htmlText;
|
||||
options.Components = rows;
|
||||
});
|
||||
}
|
||||
|
||||
public Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
|
||||
{
|
||||
// Discord thread creation is not implemented in this adapter
|
||||
return Task.FromResult(new PlatformMessageRef(PlatformKind.Discord, group.ExternalGroupId, group.ExternalThreadId, string.Empty));
|
||||
}
|
||||
|
||||
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public async Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
|
||||
{
|
||||
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
|
||||
await restClient.DeleteMessageAsync(channelId, ParseSnowflake(messageRef.ExternalMessageId));
|
||||
}
|
||||
|
||||
public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
|
||||
{
|
||||
await SendDirectContentAsync(message.Recipient, message.HtmlText, ct);
|
||||
@@ -403,6 +435,30 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
||||
return ParseSnowflake(channelId);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ActionRowProperties> BuildActionRows(IReadOnlyList<PlatformMessageAction> actions)
|
||||
{
|
||||
if (actions.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var rows = new List<ActionRowProperties>();
|
||||
foreach (var chunk in actions.Chunk(5))
|
||||
{
|
||||
var row = new ActionRowProperties();
|
||||
foreach (var action in chunk)
|
||||
{
|
||||
row.Add(new ButtonProperties(action.Key, action.Label, ButtonStyle.Secondary)
|
||||
{
|
||||
CustomId = action.Payload
|
||||
});
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static ulong ParseSnowflake(string value) =>
|
||||
ulong.Parse(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
||||
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordRescheduleHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
|
||||
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
|
||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||
|
||||
Reference in New Issue
Block a user