Files
GmRelayBot/src/GmRelay.Bot/Features/Sessions/RescheduleSession/RescheduleVotingDeadlineService.cs
T
Toutsu db9a931ed6
PR Checks / test-and-build (pull_request) Successful in 6m11s
fix(shared): filter due proposals by source_platform to prevent cross-platform race
Both Telegram and Discord deadline services were querying ALL due
proposals without filtering by source_platform. If the Telegram
service reached a Discord proposal first, it finalized the DB state
but skipped message handling. The Discord service then saw status
!= 'Voting' and never updated the Discord vote message.

Fix: GetDueProposalIdsAsync now accepts a sourcePlatform parameter
and filters at the DB level. Each service only processes its own
platform's proposals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:48:25 +03:00

242 lines
9.2 KiB
C#

using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
internal sealed record TelegramProposalFieldsDto(
int? VoteMessageId,
int? BatchMessageId,
long TelegramChatId,
int? ThreadId);
public sealed class RescheduleVotingDeadlineService(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender,
RescheduleVotingFinalizer finalizer,
ILogger<RescheduleVotingDeadlineService> 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("Telegram", ct);
foreach (var proposalId in proposalIds)
{
await FinalizeProposal(proposalId, ct);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process due reschedule voting proposals");
}
}
private async Task FinalizeProposal(Guid proposalId, CancellationToken ct)
{
var result = await finalizer.FinalizeAsync(proposalId, ct);
if (result is null)
return;
if (result.SourcePlatform != "Telegram")
{
logger.LogInformation(
"Skipping Telegram message handling for proposal {ProposalId} with source platform {SourcePlatform}",
proposalId,
result.SourcePlatform);
return;
}
await using var connection = await dataSource.OpenConnectionAsync(ct);
var telegramFields = await connection.QuerySingleOrDefaultAsync<TelegramProposalFieldsDto>(
"""
SELECT rp.vote_message_id AS VoteMessageId,
s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.id = @ProposalId
""",
new { ProposalId = proposalId });
if (telegramFields is null)
{
logger.LogWarning("Could not find Telegram fields for proposal {ProposalId}", proposalId);
return;
}
var directRecipients = result.Participants
.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName))
.ToList();
await TryUpdateVoteMessage(result, telegramFields, ct);
if (result.SelectedOption is not null)
{
await TryUpdateBatchMessage(result, telegramFields, ct);
}
var mode = SessionNotificationModeExtensions.FromDatabaseValue(result.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
await SendDirectResult(result, directRecipients, ct);
}
logger.LogInformation(
"Updated Telegram messages for finalized reschedule proposal {ProposalId} for session {SessionId}",
result.ProposalId,
result.SessionId);
}
private async Task TryUpdateVoteMessage(
RescheduleVotingFinalizerResult result,
TelegramProposalFieldsDto telegramFields,
CancellationToken ct)
{
if (telegramFields.VoteMessageId is null)
return;
try
{
var resultText = result.SelectedOption is not null
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {result.SelectedOption.DisplayOrder}: <b>{result.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(result.Decision.Reason)}";
var text = $"""
{HandleRescheduleTimeInputHandler.BuildVotingMessage(
result.Title,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Options,
result.Participants,
result.Votes)}
{resultText}
""";
await bot.EditMessageText(
chatId: telegramFields.TelegramChatId,
messageId: telegramFields.VoteMessageId.Value,
text: text,
parseMode: ParseMode.Html,
cancellationToken: ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", result.ProposalId);
}
}
private async Task TryUpdateBatchMessage(
RescheduleVotingFinalizerResult result,
TelegramProposalFieldsDto telegramFields,
CancellationToken ct)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (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 batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.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, sp.responded_at ASC, p.created_at ASC
""",
new { result.BatchId })).ToList();
if (telegramFields.BatchMessageId.HasValue)
{
var view = SessionBatchViewBuilder.Build(result.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
view,
TelegramPlatformIds.Message(telegramFields.TelegramChatId, telegramFields.ThreadId, telegramFields.BatchMessageId.Value)),
ct);
}
else
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
$"📣 Расписание обновлено после голосования за перенос сессии «{System.Net.WebUtility.HtmlEncode(result.Title)}».",
ct);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", result.ProposalId);
}
}
private async Task SendDirectResult(
RescheduleVotingFinalizerResult result,
IReadOnlyList<DirectNotificationRecipient> recipients,
CancellationToken ct)
{
var htmlText = result.SelectedOption is not null
? $"""
✅ <b>Сессия перенесена по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
📅 Новое время: <b>{result.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)
"""
: $"""
❌ <b>Перенос сессии отклонён по итогам голосования</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
📅 Время остаётся прежним: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
Причина: {System.Net.WebUtility.HtmlEncode(result.Decision.Reason)}
""";
await directSender.SendAsync(
recipients,
htmlText,
result.SelectedOption is not null ? "reschedule-vote-approved" : "reschedule-vote-rejected",
result.SessionId,
ct);
}
}