refactor: extract remaining Telegram handlers to platform-neutral contracts
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:
2026-05-27 14:52:09 +03:00
parent 383e2c1d8d
commit 542f15f2d6
45 changed files with 1648 additions and 1030 deletions
@@ -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);
}
+1
View File
@@ -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>();