306 lines
12 KiB
C#
306 lines
12 KiB
C#
using Dapper;
|
||
using GmRelay.Shared.Domain;
|
||
using GmRelay.Shared.Rendering;
|
||
using Npgsql;
|
||
using Telegram.Bot;
|
||
using Telegram.Bot.Types.ReplyMarkups;
|
||
|
||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||
|
||
public sealed record HandleRescheduleVoteCommand(
|
||
Guid ProposalId,
|
||
string Vote,
|
||
long TelegramUserId,
|
||
string CallbackQueryId,
|
||
long ChatId,
|
||
int MessageId);
|
||
|
||
internal sealed record VoteProposalDto(
|
||
Guid Id,
|
||
Guid SessionId,
|
||
DateTime ProposedAt,
|
||
string Title,
|
||
DateTime CurrentScheduledAt,
|
||
Guid BatchId,
|
||
string SessionStatus,
|
||
long TelegramChatId,
|
||
int? ConfirmationMessageId,
|
||
int? BatchMessageId);
|
||
|
||
public sealed class HandleRescheduleVoteHandler(
|
||
NpgsqlDataSource dataSource,
|
||
ITelegramBotClient bot,
|
||
ILogger<HandleRescheduleVoteHandler> logger)
|
||
{
|
||
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
||
{
|
||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||
|
||
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
|
||
"""
|
||
SELECT rp.id AS Id,
|
||
rp.session_id AS SessionId,
|
||
rp.proposed_at AS ProposedAt,
|
||
s.title AS Title,
|
||
s.scheduled_at AS CurrentScheduledAt,
|
||
s.batch_id AS BatchId,
|
||
s.status AS SessionStatus,
|
||
s.confirmation_message_id AS ConfirmationMessageId,
|
||
s.batch_message_id AS BatchMessageId,
|
||
g.telegram_chat_id AS TelegramChatId
|
||
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 AND rp.status = 'Voting'
|
||
""",
|
||
new { command.ProposalId },
|
||
transaction);
|
||
|
||
if (proposal is null)
|
||
{
|
||
await bot.AnswerCallbackQuery(
|
||
command.CallbackQueryId,
|
||
"Голосование уже завершено или не найдено.",
|
||
cancellationToken: ct);
|
||
return;
|
||
}
|
||
|
||
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.telegram_id = @TelegramUserId
|
||
AND sp.is_gm = false
|
||
""",
|
||
new { proposal.SessionId, command.TelegramUserId },
|
||
transaction);
|
||
|
||
if (playerId is null)
|
||
{
|
||
await bot.AnswerCallbackQuery(
|
||
command.CallbackQueryId,
|
||
"Вы не являетесь участником этой сессии.",
|
||
cancellationToken: ct);
|
||
return;
|
||
}
|
||
|
||
await connection.ExecuteAsync(
|
||
"""
|
||
INSERT INTO reschedule_votes (proposal_id, player_id, vote)
|
||
VALUES (@ProposalId, @PlayerId, @Vote)
|
||
ON CONFLICT (proposal_id, player_id) DO UPDATE
|
||
SET vote = EXCLUDED.vote,
|
||
voted_at = now()
|
||
""",
|
||
new { command.ProposalId, PlayerId = playerId.Value, command.Vote },
|
||
transaction);
|
||
|
||
var participants = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
|
||
? new List<VoteParticipantDto>()
|
||
: (await connection.QueryAsync<VoteParticipantDto>(
|
||
"""
|
||
SELECT p.id AS PlayerId,
|
||
p.display_name AS DisplayName,
|
||
p.telegram_username AS TelegramUsername
|
||
FROM session_participants sp
|
||
JOIN players p ON p.id = sp.player_id
|
||
WHERE sp.session_id = @SessionId AND sp.is_gm = false
|
||
""",
|
||
new { proposal.SessionId },
|
||
transaction)).ToList();
|
||
|
||
var approvedPlayerIds = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
|
||
? new HashSet<Guid>()
|
||
: (await connection.QueryAsync<Guid>(
|
||
"""
|
||
SELECT player_id
|
||
FROM reschedule_votes
|
||
WHERE proposal_id = @ProposalId AND vote = 'yes'
|
||
""",
|
||
new { command.ProposalId },
|
||
transaction)).ToHashSet();
|
||
|
||
var decision = RescheduleVoteRules.Evaluate(command.Vote, participants.Count, approvedPlayerIds.Count);
|
||
|
||
if (decision.Outcome == RescheduleVoteOutcome.Rejected)
|
||
{
|
||
await connection.ExecuteAsync(
|
||
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
|
||
new { Id = command.ProposalId },
|
||
transaction);
|
||
|
||
await transaction.CommitAsync(ct);
|
||
|
||
var voterName = await connection.QuerySingleOrDefaultAsync<string>(
|
||
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
|
||
new { command.TelegramUserId });
|
||
|
||
try
|
||
{
|
||
await bot.EditMessageText(
|
||
chatId: command.ChatId,
|
||
messageId: command.MessageId,
|
||
text: $"❌ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» отклонён!</b>\n\n{voterName ?? "Участник"} проголосовал(а) против. Время сессии остаётся прежним:\n📅 <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)",
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||
cancellationToken: ct);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex, "Failed to update vote message after rejection");
|
||
}
|
||
|
||
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
|
||
logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId);
|
||
return;
|
||
}
|
||
|
||
if (decision.ShouldRescheduleSession)
|
||
{
|
||
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero);
|
||
|
||
await connection.ExecuteAsync(
|
||
"""
|
||
UPDATE sessions
|
||
SET scheduled_at = @NewTime,
|
||
status = @Status,
|
||
confirmation_message_id = NULL,
|
||
link_message_id = NULL,
|
||
updated_at = now()
|
||
WHERE id = @SessionId
|
||
""",
|
||
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
|
||
transaction);
|
||
|
||
await connection.ExecuteAsync(
|
||
"UPDATE reschedule_proposals SET status = 'Approved' WHERE id = @Id",
|
||
new { Id = command.ProposalId },
|
||
transaction);
|
||
|
||
if (decision.ShouldResetParticipantRsvps)
|
||
{
|
||
await connection.ExecuteAsync(
|
||
"""
|
||
UPDATE session_participants
|
||
SET rsvp_status = 'Pending',
|
||
responded_at = NULL
|
||
WHERE session_id = @SessionId AND is_gm = false
|
||
""",
|
||
new { proposal.SessionId },
|
||
transaction);
|
||
}
|
||
|
||
await transaction.CommitAsync(ct);
|
||
|
||
try
|
||
{
|
||
await bot.EditMessageText(
|
||
chatId: command.ChatId,
|
||
messageId: command.MessageId,
|
||
text: $"✅ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» одобрен!</b>\n\nВсе участники согласились.\n📅 Новое время: <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК)\n\n<i>Уведомления будут приходить согласно новому расписанию.</i>",
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||
cancellationToken: ct);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex, "Failed to update vote message after approval");
|
||
}
|
||
|
||
await TryUpdateBatchMessage(proposal, ct);
|
||
|
||
logger.LogInformation(
|
||
"Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
|
||
proposal.SessionId,
|
||
newTime,
|
||
command.ProposalId);
|
||
}
|
||
else
|
||
{
|
||
await transaction.CommitAsync(ct);
|
||
|
||
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||
proposal.Title,
|
||
proposal.CurrentScheduledAt,
|
||
new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero),
|
||
participants,
|
||
approvedPlayerIds);
|
||
|
||
var keyboard = new InlineKeyboardMarkup([
|
||
[
|
||
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{command.ProposalId}"),
|
||
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{command.ProposalId}")
|
||
]
|
||
]);
|
||
|
||
try
|
||
{
|
||
await bot.EditMessageText(
|
||
chatId: command.ChatId,
|
||
messageId: command.MessageId,
|
||
text: voteText,
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||
replyMarkup: keyboard,
|
||
cancellationToken: ct);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex, "Failed to update vote message with progress");
|
||
}
|
||
}
|
||
|
||
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
|
||
}
|
||
|
||
private async Task TryUpdateBatchMessage(VoteProposalDto proposal, 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 FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||
new { proposal.BatchId })).ToList();
|
||
|
||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||
"""
|
||
SELECT sp.session_id AS SessionId,
|
||
p.display_name AS DisplayName,
|
||
p.telegram_username AS TelegramUsername
|
||
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.responded_at ASC, p.created_at ASC
|
||
""",
|
||
new { proposal.BatchId })).ToList();
|
||
|
||
if (proposal.BatchMessageId.HasValue)
|
||
{
|
||
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
|
||
|
||
await bot.EditMessageText(
|
||
chatId: proposal.TelegramChatId,
|
||
messageId: proposal.BatchMessageId.Value,
|
||
text: renderResult.Text,
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||
replyMarkup: renderResult.Markup,
|
||
cancellationToken: ct);
|
||
}
|
||
else
|
||
{
|
||
await bot.SendMessage(
|
||
chatId: proposal.TelegramChatId,
|
||
text: $"📣 Расписание обновлено! Сессия «{proposal.Title}» перенесена на <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК).",
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||
cancellationToken: ct);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex, "Failed to update batch message for proposal {ProposalId}", proposal.Id);
|
||
}
|
||
}
|
||
}
|