feat: add multi-option reschedule voting
Deploy Telegram Bot / build-and-push (push) Successful in 3m44s
Deploy Telegram Bot / deploy (push) Successful in 11s

This commit is contained in:
2026-04-27 14:58:32 +03:00
parent 2529df4157
commit a1ec688ec8
16 changed files with 929 additions and 358 deletions
@@ -25,7 +25,8 @@ internal sealed record VoteParticipantDto(
/// <summary>
/// Handles text input from the GM who has an AwaitingTime proposal.
/// Parses the new time, creates a voting message, and tags all participants.
/// Parses reschedule options with a voting deadline, creates a voting message,
/// and tags all participants.
/// If no participants are registered, reschedules immediately.
/// </summary>
public sealed class HandleRescheduleTimeInputHandler(
@@ -77,26 +78,17 @@ public sealed class HandleRescheduleTimeInputHandler(
if (proposal is null)
return false;
// 2. Parse the new time
if (!MoscowTime.TryParseMoscow(text, out var newTime))
// 2. Parse voting input
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
{
await bot.SendMessage(
chatId: chatId,
text: "⚠️ Не удалось распознать время. Используйте формат: <code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\nНапример: <code>25.04.2026 19:30</code>",
text: $"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: ct);
return true;
}
if (newTime <= DateTimeOffset.UtcNow)
{
await bot.SendMessage(
chatId: chatId,
text: "⚠️ Новое время должно быть в будущем. Попробуйте снова.",
cancellationToken: ct);
return true;
}
// 3. Load participants (non-GM) signed up for this session
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
@@ -115,35 +107,56 @@ public sealed class HandleRescheduleTimeInputHandler(
// 4. If no participants — reschedule immediately
if (participants.Count == 0)
{
await RescheduleImmediately(connection, proposal, newTime, chatId, ct);
await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct);
await TryDeleteMessage(chatId, message.MessageId, ct);
return true;
}
// 5. Create voting message
await using var transaction = await connection.BeginTransactionAsync(ct);
var options = votingInput.Options
.Select((proposedAt, index) => new RescheduleOptionDto(
Guid.NewGuid(),
index + 1,
proposedAt))
.ToList();
// Update proposal with proposed time and Voting status
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET proposed_at = @ProposedAt, status = 'Voting', vote_chat_id = @ChatId
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @ChatId
WHERE id = @Id
""",
new { ProposedAt = newTime, ChatId = chatId, Id = proposal.Id },
new { votingInput.Deadline, ChatId = chatId, Id = proposal.Id },
transaction);
foreach (var option in options)
{
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
""",
new
{
option.OptionId,
ProposalId = proposal.Id,
option.ProposedAt,
option.DisplayOrder
},
transaction);
}
await transaction.CommitAsync(ct);
// Build voting message text
var voteText = BuildVotingMessage(proposal.Title, proposal.CurrentScheduledAt, newTime, participants, []);
var keyboard = new InlineKeyboardMarkup([
[
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{proposal.Id}"),
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{proposal.Id}")
]
]);
var voteText = BuildVotingMessage(
proposal.Title,
proposal.CurrentScheduledAt,
votingInput.Deadline,
options,
participants,
[]);
var keyboard = BuildVotingKeyboard(options);
var voteMsg = await bot.SendMessage(
chatId: chatId,
@@ -155,12 +168,18 @@ public sealed class HandleRescheduleTimeInputHandler(
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
if (mode.ShouldSendDirectMessages())
{
var optionsText = string.Join(
"\n",
options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
var directText = $"""
🔄 <b>Голосование за перенос сессии</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
📅 Новое время: <b>{newTime.FormatMoscow()}</b> (МСК)
🗳 Варианты:
{optionsText}
⏳ Дедлайн: <b>{votingInput.Deadline.FormatMoscow()}</b> (МСК)
Проголосуйте кнопкой в групповом сообщении.
""";
@@ -180,7 +199,12 @@ public sealed class HandleRescheduleTimeInputHandler(
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
new { MsgId = voteMsg.MessageId, Id = proposal.Id });
logger.LogInformation("Reschedule voting started for session {SessionId}, proposal {ProposalId}", proposal.SessionId, proposal.Id);
logger.LogInformation(
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
proposal.SessionId,
proposal.Id,
options.Count,
votingInput.Deadline);
// Delete GM's time input message
await TryDeleteMessage(chatId, message.MessageId, ct);
@@ -226,33 +250,105 @@ public sealed class HandleRescheduleTimeInputHandler(
}
internal static string BuildVotingMessage(
string title, DateTime currentTime, DateTimeOffset newTime,
string title,
DateTime currentTime,
DateTimeOffset deadline,
IReadOnlyList<RescheduleOptionDto> options,
IReadOnlyList<VoteParticipantDto> participants,
IReadOnlyCollection<Guid> approvedPlayerIds)
IReadOnlyList<RescheduleOptionVoteDto> votes)
{
var votesByOption = votes
.GroupBy(v => v.OptionId)
.ToDictionary(g => g.Key, g => g.ToList());
var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet();
var pendingParticipants = participants
.Where(p => !votedPlayerIds.Contains(p.PlayerId))
.Select(FormatParticipantName)
.ToList();
var lines = new List<string>
{
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
"",
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
$"📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)",
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
"",
"Для переноса нужно согласие всех участников:"
"Выберите один из вариантов:"
};
foreach (var p in participants)
foreach (var option in options.OrderBy(x => x.DisplayOrder))
{
var name = p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
var icon = approvedPlayerIds.Contains(p.PlayerId) ? "✅" : "⏳";
lines.Add($" {icon} {name}");
var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []);
lines.Add(
$"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК) — {FormatVoteCount(optionVotes.Count)}");
if (optionVotes.Count > 0)
{
lines.Add($" {string.Join(", ", optionVotes.Select(FormatParticipantName))}");
}
}
if (pendingParticipants.Count > 0)
{
lines.Add("");
lines.Add($"Не проголосовали: {string.Join(", ", pendingParticipants)}");
}
lines.Add("");
lines.Add($"Голоса: {approvedPlayerIds.Count}/{participants.Count}");
lines.Add($"Голосов: {votedPlayerIds.Count}/{participants.Count}");
lines.Add("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется.");
return string.Join("\n", lines);
}
internal static InlineKeyboardMarkup BuildVotingKeyboard(IReadOnlyList<RescheduleOptionDto> options)
{
return new InlineKeyboardMarkup(
options
.OrderBy(option => option.DisplayOrder)
.Select(option => new[]
{
InlineKeyboardButton.WithCallbackData(
$"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}",
$"reschedule_vote:{option.OptionId}")
}));
}
internal static string FormatParticipantName(VoteParticipantDto participant)
{
return participant.TelegramUsername is { Length: > 0 } username
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
: System.Net.WebUtility.HtmlEncode(participant.DisplayName);
}
internal static string FormatParticipantName(RescheduleOptionVoteDto vote)
{
return vote.TelegramUsername is { Length: > 0 } username
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
: System.Net.WebUtility.HtmlEncode(vote.DisplayName);
}
private static string FormatVoteCount(int count)
{
var modulo100 = count % 100;
var modulo10 = count % 10;
var word = modulo100 is >= 11 and <= 14
? "голосов"
: modulo10 switch
{
1 => "голос",
>= 2 and <= 4 => "голоса",
_ => "голосов"
};
return $"{count} {word}";
}
private static string FormatButtonTime(DateTimeOffset utc)
=> utc.ToOffset(TimeSpan.FromHours(3)).ToString(
"dd.MM HH:mm",
System.Globalization.CultureInfo.InvariantCulture);
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
{
try