db9a931ed6
PR Checks / test-and-build (pull_request) Successful in 6m11s
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>
242 lines
9.2 KiB
C#
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);
|
|
}
|
|
}
|